Compare commits

..
776 changed files with 34076 additions and 75910 deletions
-23
View File
@@ -8,9 +8,6 @@ build/
**/build/
out/
target/
**/target/
bin/
version_builds/
# Gradle caches (local, not what's in the container)
.gradle/
@@ -19,15 +16,9 @@ version_builds/
# Node / frontend
node_modules/
**/node_modules/
frontend/node_modules/
frontend/dist/
.npm/
.yarn/
# Tauri/desktop builds
src-tauri/target/
src-tauri/dist/
# IDE and editor
.idea/
.vscode/
@@ -55,21 +46,7 @@ Dockerfile*
**/test-results/
**/jacoco/
# Testing and documentation (not needed in build)
testing/
docs/
*.md
README*
# Local env
.env
.env.*
!.env.example
# Misc
*.swp
*.swo
*~
.DS_Store
.cache/
.pytest_cache/
+3 -10
View File
@@ -6,20 +6,14 @@ openapi: &openapi
- *build
- app/(common|core|proprietary)/src/main/java/**
docker-base: &docker-base
- docker/base/Dockerfile
- ".github/workflows/push-docker-base.yml"
docker: &docker
- docker/embedded/Dockerfile
- docker/embedded/Dockerfile.fat
- docker/embedded/Dockerfile.ultra-lite
- Dockerfile
- Dockerfile.fat
- Dockerfile.ultra-lite
- ".github/workflows/build.yml"
- ".github/workflows/push-docker.yml"
- scripts/init.sh
- scripts/init-without-ocr.sh
- exampleYmlFiles/**
- *docker-base
project: &project
- app/(common|core|proprietary)/src/(main|test)/java/**
@@ -30,7 +24,6 @@ project: &project
- libs/**
- "testing/**/!(requirements*.txt|requirements*.in)*"
- *docker
- *docker-base
- gradle.properties
- gradlew
- gradlew.bat
+7 -80
View File
@@ -30,7 +30,6 @@ jobs:
project: ${{ steps.changes.outputs.project }}
openapi: ${{ steps.changes.outputs.openapi }}
frontend: ${{ steps.changes.outputs.frontend }}
docker-base: ${{ steps.changes.outputs.docker-base }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
@@ -70,7 +69,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
~/.gradle/wrapper
@@ -155,7 +154,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
~/.gradle/wrapper
@@ -217,39 +216,6 @@ jobs:
path: frontend/dist/
retention-days: 3
playwright-e2e:
if: needs.files-changed.outputs.frontend == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Generate icons
run: cd frontend && node scripts/generate-icons.js
- name: Install Playwright (chromium only)
run: cd frontend && npx playwright install chromium --with-deps
- name: Run E2E tests (chromium)
run: cd frontend && npx playwright test --project=chromium
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: playwright-report-pr-${{ github.run_id }}
path: frontend/playwright-report/
retention-days: 7
check-licence:
if: needs.files-changed.outputs.build == 'true'
needs: [files-changed, build]
@@ -270,7 +236,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
~/.gradle/wrapper
@@ -343,7 +309,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
~/.gradle/wrapper
@@ -391,7 +357,6 @@ jobs:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DOCKER_BASE_CHANGED: ${{ needs.files-changed.outputs.docker-base }}
- name: Upload Cucumber Report
if: always()
@@ -402,15 +367,6 @@ jobs:
retention-days: 7
if-no-files-found: warn
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: docker-compose-test-reports
path: testing/reports/
retention-days: 7
if-no-files-found: warn
- name: Cucumber Test Report
if: always()
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
@@ -446,17 +402,6 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Free disk space on runner
run: |
echo "Disk space before cleanup:" && df -h
@@ -471,7 +416,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
~/.gradle/wrapper
@@ -495,28 +440,12 @@ jobs:
STIRLING_PDF_DESKTOP_UI: false
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build base image locally (PR base change only)
if: github.event_name == 'pull_request' && needs.files-changed.outputs.docker-base == 'true'
run: |
docker build -t stirling-pdf-base:pr-test -f docker/base/Dockerfile docker/base
- name: Set base image and platform for this build
id: build-params
run: |
if [ "${{ github.event_name }}" == "pull_request" ] && [ "${{ needs.files-changed.outputs.docker-base }}" == "true" ]; then
echo "base_image=stirling-pdf-base:pr-test" >> $GITHUB_OUTPUT
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
else
echo "base_image=ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-base:latest" >> $GITHUB_OUTPUT
echo "platforms=linux/amd64,linux/arm64/v8" >> $GITHUB_OUTPUT
fi
- name: Build ${{ matrix.docker-rev }}
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
@@ -526,9 +455,7 @@ jobs:
push: false
cache-from: type=gha,scope=${{ matrix.cache-scope }}
cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }}
platforms: ${{ steps.build-params.outputs.platforms }}
build-args: |
BASE_IMAGE=${{ steps.build-params.outputs.base_image }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run Labeler
uses: crazy-max/ghaction-github-labeler@548a7c3603594ec17c819e1239f281a3b801ab4d # v6.0.0
uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
yaml-file: .github/labels.yml
+2 -5
View File
@@ -49,7 +49,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
~/.gradle/caches
@@ -182,9 +182,6 @@ jobs:
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
allowed-endpoints: >
one.digicert.com:443
clientauth.one.digicert.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -591,7 +588,7 @@ jobs:
run: ls -R ./artifacts
- name: Upload binaries to Release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
generate_release_notes: true
-53
View File
@@ -1,53 +0,0 @@
name: Nightly E2E Tests
on:
schedule:
- cron: "0 2 * * *" # 2 AM UTC every night
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
playwright-all-browsers:
name: Playwright (chromium + firefox + webkit)
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Generate icons
run: cd frontend && node scripts/generate-icons.js
- name: Install all Playwright browsers
run: cd frontend && npx playwright install --with-deps
- name: Run E2E tests (all browsers)
run: cd frontend && npx playwright test
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: playwright-nightly-${{ github.run_id }}
path: frontend/playwright-report/
retention-days: 14
-119
View File
@@ -1,119 +0,0 @@
name: Push Docker Base Image
on:
push:
branches:
- baseDockerImage
workflow_dispatch:
inputs:
version:
description: 'Base image version (e.g., 1.0.0, 1.0.1)'
required: true
type: string
permissions:
contents: read
jobs:
push-base:
if: ${{ vars.CI_PROFILE != 'lite' && github.actor == 'Frooodle' }}
runs-on: ubuntu-24.04-8core
permissions:
packages: write
id-token: write
steps:
- name: Verify authorized user
run: |
if [ "${{ github.actor }}" != "Frooodle" ]; then
echo "Error: Only Frooodle is authorized to run this workflow"
exit 1
fi
- name: Set version
id: version
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
VERSION="${{ github.event.inputs.version }}"
else
VERSION="1.0.0"
fi
echo "version=${VERSION}" >> $GITHUB_OUTPUT
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Generate tags for base image
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: |
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-base
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-base
tags: |
type=raw,value=${{ steps.version.outputs.version }}
- name: Build and push base image
id: build-push-base
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: docker/base
file: ./docker/base/Dockerfile
push: true
cache-from: type=gha,scope=stirling-pdf-base
cache-to: type=gha,mode=max,scope=stirling-pdf-base
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Install cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.4.1"
- name: Sign base images
env:
DIGEST: ${{ steps.build-push-base.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
if [ -n "$COSIGN_PRIVATE_KEY" ]; then
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --yes \
--key env://COSIGN_PRIVATE_KEY \
"${tag}@${DIGEST}"
done
else
echo "Warning: COSIGN_PRIVATE_KEY not set, skipping image signing"
fi
+5 -7
View File
@@ -46,7 +46,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
~/.gradle/caches
@@ -74,13 +74,13 @@ jobs:
- name: Install cosign
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.4.1"
- name: Install cosign
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.4.1"
@@ -98,7 +98,7 @@ jobs:
password: ${{ github.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Convert repository owner to lowercase
id: repoowner
@@ -130,9 +130,7 @@ jobs:
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
BASE_VERSION=1.0.0
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
+2 -9
View File
@@ -29,14 +29,8 @@ clientWebUI/
exampleYmlFiles/stirling/
/stirling/
/testing/file_snapshots
/testing/cucumber/junit/
/testing/cucumber/report.html
/testing/.failed_tests
SwaggerDoc.json
# Runtime storage for uploaded files and user data (not Java source code)
app/core/storage/
# Frontend build artifacts copied to backend static resources
# These are generated by npm build and should not be committed
app/core/src/main/resources/static/assets/
@@ -163,6 +157,8 @@ __pycache__/
*.pyo
# Virtual environments
.env*
!.env*.example
.venv*
env*/
venv*/
@@ -201,9 +197,6 @@ out/
*.jks
*.asc
# Allow test fixture certificates (synthetic, no real credentials)
!frontend/src/core/tests/test-fixtures/certs/**
# SSH Keys
*.pub
*.priv
+15 -10
View File
@@ -20,12 +20,16 @@ This file provides guidance to AI Agents when working with code in this reposito
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
### Python Development
Development for the AI engine happens in the `engine/` folder. The frontend calls the Python via Java as a proxy.
Development for the AI engine happens in the `engine/` folder. It's built with Langchain and Pydantic and allows for the creation and editing of PDF documents. The frontend calls the Python via Java as a proxy.
- Follow the engine-specific guidance in [engine/AGENTS.md](engine/AGENTS.md) for Python architecture, code style, and AI usage.
- Python version is 3.13; use modern Python features (type aliases, pattern matching, dataclasses, etc.) where they help clarity.
- Write fully type-correct code; keep pyright clean and avoid `Any` unless strictly necessary.
- JSON handling: deserialize into fully typed Pydantic models as early as possible, and serialize back from Pydantic models as late as possible.
- Use Makefile commands for Python work:
- From `engine/`: `make check` to lint, type-check, test, etc. and `make fix` to fix easily fixable linting and formatting issues.
- The project structure is defined in `engine/pyproject.toml`. Any new dependencies should be listed there, followed by running `make install`.
- From `engine/`: `make check` to lint, type-check, test, etc. and `make fix` to fix easily fixable linting & formatting issues.
- The project structure is defined in `engine/pyproject.toml`. Any new dependencies should be listed appropriately there, followed by running `make install`.
- Prefer using classes to nesting functions, and make other similar architectural decisions to improve testability. Do not nest classes or functions unless specifically required to for the code construct (like a decorator).
- All environment variables used within the code must begin with the `STIRLING_` prefix in order to keep them unique and easier to find.
### Frontend Development
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
@@ -39,12 +43,13 @@ Development for the AI engine happens in the `engine/` folder. The frontend call
- **Development**: `npm run tauri-dev` for desktop dev mode
#### Environment Variables
- Committed `.env` files contain defaults; local overrides go in `.env.local` / `.env.*.local` (gitignored):
- `frontend/.env` — core, proprietary, and shared vars
- `frontend/.env.saas` — SaaS-only vars
- `frontend/.env.desktop` — desktop (Tauri)-only vars
- All `VITE_*` variables must be declared in one of these committed `.env` files
- Never use `|| 'hardcoded-fallback'` inline — put defaults in the `.env` files
- All `VITE_*` variables must be declared in the appropriate example file:
- `frontend/config/.env.example` — core, proprietary, and shared vars
- `frontend/config/.env.saas.example` — SaaS-only vars
- `frontend/config/.env.desktop.example` — desktop (Tauri)-only vars
- Never use `|| 'hardcoded-fallback'` inline — put defaults in the example files
- `npm run prep` / `prep:saas` / `prep:desktop` auto-create the env files from examples on first run, and error if any required keys are missing
- These prep scripts run automatically at the start of all `dev*`, `build*`, and `tauri*` commands
- See `frontend/README.md#environment-variables` for full documentation
#### Import Paths - CRITICAL
-444
View File
@@ -1,444 +0,0 @@
# File Sharing Feature - Architecture & Workflow
## Overview
The File Sharing feature enables users to store files server-side and share them with other registered users or via token-based share links. Files are stored using a pluggable storage provider (local filesystem or database) with optional quota enforcement.
**Key Capabilities:**
- Server-side file storage (upload, update, download, delete)
- Optional history bundle and audit log attachments per file
- Direct user-to-user sharing with access roles
- Token-based share links (requires `system.frontendUrl`)
- Optional email notifications for shares (requires `mail.enabled`)
- Access audit trail (tracks who accessed a share link and how)
- Automatic share link expiration
- Storage quotas (per-user and total)
- Pluggable storage backend (local filesystem or database BLOB)
- Integration with the Shared Signing workflow
## Architecture
### Database Schema
**`stored_files`**
- One record per uploaded file
- Stores file metadata (name, content type, size, storage key)
- Optionally links to a history bundle and audit log as separate stored objects
- `workflow_session_id` — nullable link to a `WorkflowSession` (signing feature)
- `file_purpose` — enum classifying the file's role: `GENERIC`, `SIGNING_ORIGINAL`, `SIGNING_SIGNED`, `SIGNING_HISTORY`
**`file_shares`**
- One record per sharing relationship
- Two share types, distinguished by which fields are set:
- **User share**: `shared_with_user_id` is set, `share_token` is null
- **Link share**: `share_token` is set (UUID), `shared_with_user_id` is null
- `access_role``EDITOR`, `COMMENTER`, or `VIEWER`
- `expires_at` — nullable expiration for link shares
- `workflow_participant_id` — when set, marks this as a **workflow share** (hidden from the file manager, accessible only via workflow endpoints)
**`file_share_accesses`**
- One record per access event on a share link
- Tracks: user, share link, access type (`VIEW` or `DOWNLOAD`), timestamp
**`storage_cleanup_entries`**
- Queue of storage keys to be deleted asynchronously
- Used when a file is deleted but the physical storage object cleanup is deferred
### Access Roles
| Role | Can Read | Can Write |
|------|----------|-----------|
| `EDITOR` | ✅ | ✅ |
| `COMMENTER` | ✅ | ❌ |
| `VIEWER` | ✅ | ❌ |
Default role when none is specified: `EDITOR`.
Owners always have full access regardless of role.
#### Role Semantics: COMMENTER vs VIEWER
In the file storage layer, `COMMENTER` and `VIEWER` are equivalent — both grant read-only access and neither can replace file content. The distinction is meaningful in the **signing workflow** context:
| Context | COMMENTER | VIEWER |
|---------|-----------|--------|
| File storage | Read only (same as VIEWER) | Read only |
| Signing workflow | Can submit a signing action | Read only |
`WorkflowParticipant.canEdit()` returns `true` for `COMMENTER` (and `EDITOR`) roles, which the signing workflow uses to determine if a participant can still submit a signature. Once a participant has signed or declined, their effective role is automatically downgraded to `VIEWER` regardless of their configured role.
The rationale: "annotating" a document (submitting a signature) is not the same as "replacing" it. COMMENTER grants annotation rights without file-replacement rights.
### Backend Architecture
#### Service Layer
**FileStorageService** (`1137 lines`)
- Core file management service
- Upload, update, download, and delete operations
- User share management (share, revoke, leave)
- Link share management (create, revoke, access)
- Access recording and listing
- Storage quota enforcement
- Configuration feature gate checks
**StorageCleanupService**
- Scheduled daily: deletes orphaned storage keys from `storage_cleanup_entries`
- Scheduled daily: purges expired share links from `file_shares`
- Processes cleanup in batches of 50 entries
#### Storage Providers
**LocalStorageProvider**
- Files stored on the filesystem under `storage.local.basePath` (default: `./storage`)
- Storage key is a path relative to the base directory
**DatabaseStorageProvider**
- Files stored as BLOBs in `stored_file_blobs` table
- No filesystem dependency
Provider is selected at startup via `storage.provider: local | database`.
#### Controller Layer
**FileStorageController** (`/api/v1/storage`)
- All endpoints require authentication
- File CRUD and sharing operations
### Data Flow
```
User uploads file → StorageProvider stores bytes → StoredFile record created
Owner shares file → FileShare record created (user or link)
Recipient accesses file → Access recorded → File bytes streamed
```
## File Operations
### Upload File
```bash
POST /api/v1/storage/files
Content-Type: multipart/form-data
file: document.pdf # Required — main file
historyBundle: history.json # Optional — version history
auditLog: audit.json # Optional — audit trail
```
**Response:**
```json
{
"id": 42,
"fileName": "document.pdf",
"contentType": "application/pdf",
"sizeBytes": 102400,
"owner": "alice",
"ownedByCurrentUser": true,
"accessRole": "editor",
"createdAt": "2025-01-01T12:00:00",
"updatedAt": "2025-01-01T12:00:00",
"sharedWithUsers": [],
"sharedUsers": [],
"shareLinks": []
}
```
### Update File
Replaces the file content. Only the owner can update.
```bash
PUT /api/v1/storage/files/{fileId}
Content-Type: multipart/form-data
file: document_v2.pdf
historyBundle: history.json # Optional
auditLog: audit.json # Optional
```
### List Files
Returns all files owned by or shared with the current user. Workflow-shared files (signing participants) are excluded — those are accessible via signing endpoints only.
```bash
GET /api/v1/storage/files
```
Response is sorted by `createdAt` descending.
### Download File
```bash
GET /api/v1/storage/files/{fileId}/download?inline=false
```
- `inline=false` (default) — `Content-Disposition: attachment`
- `inline=true``Content-Disposition: inline` (for browser preview)
### Delete File
Only the owner can delete. All associated share links and their access records are deleted first, then the database record, then the physical storage object.
```bash
DELETE /api/v1/storage/files/{fileId}
```
## Sharing Operations
### Share with User
```bash
POST /api/v1/storage/files/{fileId}/shares/users
Content-Type: application/json
{
"username": "bob", # Username or email address
"accessRole": "editor" # "editor", "commenter", or "viewer" (default: "editor")
}
```
**Behaviour:**
- If the target user exists: creates/updates a `FileShare` with `sharedWithUser` set
- If `username` is an email address and the user doesn't exist: creates a share link and sends a notification email (requires `sharing.emailEnabled` and `sharing.linkEnabled`)
- If the target user is the owner: returns 400
- If sharing is disabled: returns 403
### Revoke User Share
Only the owner can revoke.
```bash
DELETE /api/v1/storage/files/{fileId}/shares/users/{username}
```
### Leave Shared File
The recipient removes themselves from a shared file.
```bash
DELETE /api/v1/storage/files/{fileId}/shares/self
```
### Create Share Link
Creates a token-based link for anonymous/authenticated access. Requires `sharing.linkEnabled` and `system.frontendUrl` to be configured.
```bash
POST /api/v1/storage/files/{fileId}/shares/links
Content-Type: application/json
{
"accessRole": "viewer" # Optional (default: "editor")
}
```
**Response:**
```json
{
"token": "550e8400-e29b-41d4-a716-446655440000",
"accessRole": "viewer",
"createdAt": "2025-01-01T12:00:00",
"expiresAt": "2025-01-04T12:00:00"
}
```
Expiration is set to `now + sharing.linkExpirationDays` (default: 3 days).
### Revoke Share Link
```bash
DELETE /api/v1/storage/files/{fileId}/shares/links/{token}
```
Also deletes all access records for that token.
## Share Link Access
### Download via Share Link
Authentication is required (even for share links). Anonymous access is not permitted.
```bash
GET /api/v1/storage/share-links/{token}?inline=false
```
- Returns 401 if unauthenticated
- Returns 403 if authenticated but link doesn't permit access
- Returns 410 if the link has expired
- Records a `FileShareAccess` entry on success
> **Token-as-credential semantics:** Any authenticated user who holds the token can access the file — the token is the credential. If you need per-user access control (only a specific person can open it), use "Share with User" instead. Share links are appropriate for broader distribution where possession of the token implies authorization.
### Get Share Link Metadata
```bash
GET /api/v1/storage/share-links/{token}/metadata
```
Returns file name, owner, access role, creation/expiry timestamps, and whether the current user owns the file.
### List Accessed Share Links
Returns the most recent access for each non-expired share link the current user has accessed.
```bash
GET /api/v1/storage/share-links/accessed
```
### List Accesses for a Link (Owner Only)
```bash
GET /api/v1/storage/files/{fileId}/shares/links/{token}/accesses
```
Returns per-user access history (username, VIEW/DOWNLOAD, timestamp), sorted descending by time.
## Workflow Share Integration
Signing workflow participants access documents via their own `WorkflowParticipant.shareToken`. No `FileShare` record is created for participants; access control is self-contained in the `WorkflowParticipant` entity.
The `FileShare.workflow_participant_id` column and the `FileShare.isWorkflowShare()` method are **deprecated**. Legacy data (sessions created before this change) may still have `FileShare` records with `workflow_participant_id` set, which continue to work via the existing token lookup path in `UnifiedAccessControlService`. No new records are created.
`GET /api/v1/storage/files` returns all files owned by or shared with the current user (via `FileShare`). Signing-session PDFs use the `file_purpose` field (`SIGNING_ORIGINAL`, `SIGNING_SIGNED`, etc.) to distinguish them from generic files. The file manager UI can filter on this field if needed.
## API Reference
| Method | Endpoint | Description | Auth |
|--------|----------|-------------|------|
| POST | `/api/v1/storage/files` | Upload file | Required |
| PUT | `/api/v1/storage/files/{id}` | Update file | Required (owner) |
| GET | `/api/v1/storage/files` | List accessible files | Required |
| GET | `/api/v1/storage/files/{id}` | Get file metadata | Required |
| GET | `/api/v1/storage/files/{id}/download` | Download file | Required |
| DELETE | `/api/v1/storage/files/{id}` | Delete file | Required (owner) |
| POST | `/api/v1/storage/files/{id}/shares/users` | Share with user | Required (owner) |
| DELETE | `/api/v1/storage/files/{id}/shares/users/{username}` | Revoke user share | Required (owner) |
| DELETE | `/api/v1/storage/files/{id}/shares/self` | Leave shared file | Required |
| POST | `/api/v1/storage/files/{id}/shares/links` | Create share link | Required (owner) |
| DELETE | `/api/v1/storage/files/{id}/shares/links/{token}` | Revoke share link | Required (owner) |
| GET | `/api/v1/storage/share-links/{token}` | Download via share link | Required |
| GET | `/api/v1/storage/share-links/{token}/metadata` | Get share link metadata | Required |
| GET | `/api/v1/storage/share-links/accessed` | List accessed share links | Required |
| GET | `/api/v1/storage/files/{id}/shares/links/{token}/accesses` | List share accesses | Required (owner) |
## Configuration
All storage settings live under the `storage:` key in `settings.yml`:
```yaml
storage:
enabled: true # Requires security.enableLogin = true
provider: local # 'local' or 'database'
local:
basePath: './storage' # Filesystem base directory (local provider only)
quotas:
maxStorageMbPerUser: -1 # Per-user storage cap in MB; -1 = unlimited
maxStorageMbTotal: -1 # Total storage cap in MB; -1 = unlimited
maxFileMb: -1 # Max size per upload (main + history + audit) in MB; -1 = unlimited
sharing:
enabled: false # Master switch for all sharing (opt-in)
linkEnabled: false # Enable token-based share links (requires system.frontendUrl)
emailEnabled: false # Enable email notifications (requires mail.enabled)
linkExpirationDays: 3 # Days until share links expire
```
**Prerequisites:**
- `storage.enabled` requires `security.enableLogin = true`
- `sharing.linkEnabled` requires `system.frontendUrl` to be set (used to build share link URLs)
- `sharing.emailEnabled` requires `mail.enabled = true`
## Security Considerations
### Access Control
- All endpoints require authentication — there is no anonymous access
- Owner-only operations enforced in service layer (not just controller)
- `requireReadAccess` / `requireEditorAccess` checked on every download
### Share Link Security
- Tokens are UUIDs (random, not guessable)
- Expiration enforced on every access
- Expired links return HTTP 410 Gone
- Revoked links delete all access records
### Quota Enforcement
- Checked before storing (not after)
- Accounts for existing file size when replacing (only the delta counts)
- Covers main file + history bundle + audit log in a single check
## Automatic Cleanup
`StorageCleanupService` runs two scheduled jobs daily:
1. **Orphaned storage cleanup** — processes up to 50 `StorageCleanupEntry` records, deletes the physical storage object, then removes the entry. Failed attempts increment `attemptCount` for retry.
2. **Expired share link cleanup** — deletes all `FileShare` records where `expiresAt` is in the past and `shareToken` is set.
## Troubleshooting
**"Storage is disabled":**
- Check `storage.enabled: true` in settings
- Verify `security.enableLogin: true`
**"Share links are disabled":**
- Check `sharing.linkEnabled: true`
- Verify `system.frontendUrl` is set and non-empty
**"Email sharing is disabled":**
- Check `sharing.emailEnabled: true`
- Verify `mail.enabled: true` and mail configuration
**Signing-session PDF appearing in the general file list:**
- This is expected — signing PDFs are accessible to owners and shared users
- Filter by `file_purpose` (`SIGNING_ORIGINAL`, `SIGNING_SIGNED`) in the UI to distinguish them
**Share link returns 410:**
- Link has expired — check `expires_at` in `file_shares` table
- Owner must create a new link
### Debug Queries
```sql
-- List files and their share counts
SELECT sf.stored_file_id, sf.original_filename, u.username as owner,
COUNT(DISTINCT fs.file_share_id) FILTER (WHERE fs.shared_with_user_id IS NOT NULL) as user_shares,
COUNT(DISTINCT fs.file_share_id) FILTER (WHERE fs.share_token IS NOT NULL) as link_shares
FROM stored_files sf
LEFT JOIN users u ON sf.owner_id = u.user_id
LEFT JOIN file_shares fs ON fs.stored_file_id = sf.stored_file_id
GROUP BY sf.stored_file_id, u.username;
-- Check share link expiration
SELECT share_token, access_role, created_at, expires_at,
expires_at < NOW() as is_expired
FROM file_shares
WHERE share_token IS NOT NULL;
-- Check access history for a share link
SELECT u.username, fsa.access_type, fsa.accessed_at
FROM file_share_accesses fsa
JOIN file_shares fs ON fsa.file_share_id = fs.file_share_id
JOIN users u ON fsa.user_id = u.user_id
WHERE fs.share_token = '{token}'
ORDER BY fsa.accessed_at DESC;
-- Pending cleanup entries
SELECT storage_key, attempt_count, updated_at
FROM storage_cleanup_entries
ORDER BY updated_at ASC;
```
## Summary
The File Sharing feature provides:
- ✅ Server-side file storage with pluggable backend (local/database)
- ✅ History bundle and audit log attachments per file
- ✅ Direct user-to-user sharing with EDITOR/COMMENTER/VIEWER roles
- ✅ Token-based share links with expiration
- ✅ Optional email notifications for shares
- ✅ Per-access audit trail for share links
- ✅ Storage quotas (per-user, total, per-file)
- ✅ Automatic cleanup of expired links and orphaned storage
- ✅ Workflow integration (signing-session PDFs stored via same infrastructure; participant access via `WorkflowParticipant.shareToken`)
-691
View File
@@ -1,691 +0,0 @@
# Shared Signing Feature - Architecture & Workflow
## Overview
The Shared Signing feature enables collaborative document signing workflows where a document owner can request signatures from multiple participants. Each participant receives a secure token to access the document, submit their digital signature (with optional wet signature overlay), and track the signing progress.
**Key Capabilities:**
- Multi-participant signing sessions
- Digital certificate signatures (P12/PKCS12, JKS, SERVER, USER_CERT, PEM/UPLOAD)
- Visual wet signature overlays (drawn, typed, or uploaded) — multiple per participant
- Token-based participant access (no authentication required for participants)
- Authenticated participant access for registered users via sign-requests API
- Progress tracking for session owners
- Optional signature summary page appended to finalized PDF
- Automatic role downgrade after signing (security)
- GDPR-compliant wet signature metadata cleanup
## Architecture
### Database Schema
#### Core Tables
**`workflow_sessions`**
- Tracks signing sessions created by document owners
- Links to original and processed (signed) PDF files
- Stores session metadata (message, due date, status)
**`workflow_participants`**
- One record per participant per session
- Tracks participant status: PENDING → VIEWED → SIGNED/DECLINED
- `NOTIFIED` status is reserved for a future email notification feature; no current code path sets it
- Stores participant-specific metadata (certificates, wet signatures) as JSONB
- Each participant holds their own `shareToken` (UUID) for token-based access — no separate `FileShare` record is created
- `accessRole` controls what actions the participant can perform. `COMMENTER` (and `EDITOR`) allow submitting a signature; `VIEWER` does not. After signing/declining, effective role is automatically downgraded to `VIEWER`
**`user_server_certificates`**
- Stores auto-generated certificates per user
- Enables "Use My Personal Certificate" option
#### Extended Tables
**`stored_files`**
- Added `workflow_session_id` to link files to signing sessions
- Added `file_purpose` enum (SIGNING_ORIGINAL, SIGNING_SIGNED, etc.)
**`file_shares`**
- Regular file shares are created when the session owner shares the document with other users via the file manager
- The `workflow_participant_id` column is deprecated; participant access is self-contained in `WorkflowParticipant.shareToken`
### Backend Architecture
#### Service Layer
**WorkflowSessionService** (`816 lines`)
- Core workflow management service
- Creates sessions with participants
- Handles participant status updates
- Stores signature metadata (certificates and wet signatures)
- Finalizes sessions by coordinating signing process
Key responsibilities:
- Session lifecycle management (create, list, get details, delete)
- Participant management (add, remove, notify)
- Certificate submission storage
- Wet signature metadata storage
- Session finalization orchestration
**UnifiedAccessControlService**
- Validates participant tokens
- Checks session status and expiration
- Maps participant status to effective access role
- Automatic role downgrade after signing: SIGNED/DECLINED → VIEWER role
**UserServerCertificateService**
- Auto-generates personal certificates for users
- Manages certificate storage and retrieval
- Enables "Use My Personal Certificate" signing option
#### Controller Layer
**SigningSessionController** (Owner-facing + Authenticated participant endpoints)
- `POST /api/v1/security/cert-sign/sessions` - Create signing session
- `GET /api/v1/security/cert-sign/sessions` - List user's sessions
- `GET /api/v1/security/cert-sign/sessions/{id}` - Get session details
- `GET /api/v1/security/cert-sign/sessions/{id}/pdf` - Download original PDF
- `POST /api/v1/security/cert-sign/sessions/{id}/finalize` - Finalize and apply signatures
- `GET /api/v1/security/cert-sign/sessions/{id}/signed-pdf` - Download signed PDF
- `DELETE /api/v1/security/cert-sign/sessions/{id}` - Delete session
- `POST /api/v1/security/cert-sign/sessions/{id}/participants` - Add participants
- `DELETE /api/v1/security/cert-sign/sessions/{id}/participants/{participantId}` - Remove participant
- `GET /api/v1/security/cert-sign/sign-requests` - List sign requests for authenticated user
- `GET /api/v1/security/cert-sign/sign-requests/{id}` - Get sign request details
- `GET /api/v1/security/cert-sign/sign-requests/{id}/document` - Download document for signing
- `POST /api/v1/security/cert-sign/sign-requests/{id}/sign` - Sign document (authenticated)
- `POST /api/v1/security/cert-sign/sign-requests/{id}/decline` - Decline sign request (authenticated)
**WorkflowParticipantController** (Participant-facing, token-based)
- `GET /api/v1/workflow/participant/session?token={token}` - View session details
- `GET /api/v1/workflow/participant/details?token={token}` - Get participant details
- `GET /api/v1/workflow/participant/document?token={token}` - Download PDF
- `POST /api/v1/workflow/participant/submit-signature` - Submit signature
- `POST /api/v1/workflow/participant/decline?token={token}` - Decline to sign
#### Data Flow
```
Owner creates session → Participants receive tokens →
Participants access via token (or authenticated) → Participants submit signatures →
Owner finalizes → System applies signatures → [Optional: append summary page] → Signed PDF generated
```
### Frontend Architecture
#### Quick Access Integration
**SignPopout Component**
- Displays in Quick Access Bar (top navigation)
- Shows active and completed signing sessions
- Auto-refreshes every 15 seconds to show signature progress
- Badge indicator shows count of pending sessions
**ActiveSessionsPanel**
- Lists sessions where user is owner or participant
- Shows signature progress: "X/Y signatures" (e.g., "2/5 signatures")
- Color-coded badges:
- Blue: No signatures yet (0/X)
- Yellow: Partial signatures (X/Y)
- Green: Ready to finalize (X/X)
**CompletedSessionsPanel**
- Lists finalized sessions and declined sign requests
- Allows viewing/downloading signed PDFs
#### Workbench Views
**SignRequestWorkbenchView**
- Full-screen view for participants to sign documents
- Integrated PDF viewer with annotation support
- Certificate selection (Personal/Organization/Custom P12)
- Wet signature input (draw, type, or upload)
- Signature placement on PDF pages
**SessionDetailWorkbenchView**
- Owner's view of session details
- Participant list with status indicators
- Ability to add/remove participants
- Finalize button when all signatures collected
- Download original/signed PDF
#### State Management
**FileContext Integration**
- Signing sessions operate within FileContext workflow
- PDFs loaded once, persist across tool switches
- Memory management for large files (up to 100GB+)
**ToolWorkflowContext**
- Registers custom workbench views
- Manages navigation between viewer and signing tools
- Preserves file state during signing operations
#### Services & Hooks
**workflowService.ts**
- API client for all signing endpoints
- Handles session creation, listing, and management
- Participant operations (submit, decline)
**useWorkflowSession.ts**
- React hook for owner session management
- State management for session list and details
**useParticipantSession.ts**
- React hook for participant signing workflow
- Manages signature submission state
## Signing Workflow Process
### 1. Session Creation (Owner)
```
Owner → Uploads PDF → Selects participants → Creates session
System creates:
- WorkflowSession record
- WorkflowParticipant records (one per participant, each with a unique shareToken)
Participants receive token (via email or share link)
```
**API Call:**
```bash
POST /api/v1/security/cert-sign/sessions
Content-Type: multipart/form-data
file: document.pdf
workflowType: SIGNING
documentName: "contract.pdf" # Optional display name
participantUserIds: [1, 2, 3] # Registered user IDs
participantEmails: ["a@b.com"] # External/unregistered users
participants: [...] # Detailed participant configs (optional)
message: "Please sign this contract"
dueDate: "2025-12-31"
ownerEmail: "owner@example.com" # Optional, for notifications
workflowMetadata: '{"showSignature": false, "showLogo": false, "includeSummaryPage": true}'
```
**Session-level `workflowMetadata` fields:**
| Field | Type | Description |
|-------|------|-------------|
| `showSignature` | boolean | Show visible digital signature block on PDF |
| `pageNumber` | integer | Page to place digital signature on |
| `showLogo` | boolean | Show logo in digital signature block |
| `includeSummaryPage` | boolean | Append a signature summary page before digital signing |
**Response:**
```json
{
"sessionId": "uuid",
"documentName": "contract.pdf",
"participants": [
{
"userId": 1,
"email": "user1@example.com",
"shareToken": "token1",
"status": "PENDING"
}
],
"participantCount": 3,
"signedCount": 0
}
```
### 2. Participant Access
```
Participant → Clicks token link → Views session details
Status changes: PENDING/NOTIFIED → VIEWED
Participant downloads PDF to review
```
**Access URL (unauthenticated):**
```
https://app.example.com/sign?token={participant_token}
```
**Authenticated participants** can also use:
```
GET /api/v1/security/cert-sign/sign-requests
GET /api/v1/security/cert-sign/sign-requests/{sessionId}
GET /api/v1/security/cert-sign/sign-requests/{sessionId}/document
```
**Automatic Status Update:**
- First access: PENDING/NOTIFIED → VIEWED
- Downloads tracked but don't change status
### 3. Signature Submission
```
Participant → Selects certificate type → Uploads certificate (if needed)
→ Draws/uploads wet signatures (optional, multiple supported)
→ Submits signature
System stores:
- Certificate data (P12/JKS keystore as base64)
- Certificate password
- Wet signatures metadata (JSON array: base64 image + coordinates per signature)
Status changes: VIEWED → SIGNED
Access role: EDITOR → VIEWER (automatic downgrade)
```
**API Call (token-based, unauthenticated):**
```bash
POST /api/v1/workflow/participant/submit-signature
Content-Type: multipart/form-data
participantToken: {token}
certType: P12 | JKS | SERVER | USER_CERT
p12File: certificate.p12 (if certType=P12)
jksFile: keystore.jks (if certType=JKS)
password: cert_password
showSignature: false
pageNumber: 1
location: "New York"
reason: "I approve this contract"
showLogo: false
wetSignaturesData: '[{"page":0,"x":100,"y":200,"width":150,"height":50,"type":"IMAGE","data":"base64..."}]'
```
**API Call (authenticated users):**
```bash
POST /api/v1/security/cert-sign/sign-requests/{sessionId}/sign
Content-Type: multipart/form-data
certType: SERVER | USER_CERT | UPLOAD | PEM | PKCS12 | PFX | JKS
p12File: certificate.p12 (if applicable)
password: cert_password
reason: "I approve this contract"
location: "New York"
wetSignaturesData: '[...]'
```
**Metadata Storage (JSONB):**
```json
{
"certificateSubmission": {
"certType": "P12",
"password": "cert_password",
"p12Keystore": "base64_encoded_keystore",
"showSignature": false,
"pageNumber": 1,
"location": "New York",
"reason": "I approve this contract",
"showLogo": false
},
"wetSignatures": [
{
"type": "IMAGE",
"data": "base64_image",
"page": 0,
"x": 100,
"y": 200,
"width": 150,
"height": 50
}
]
}
```
Note: Multiple wet signatures are supported per participant (array).
### 4. Progress Tracking (Owner)
```
Owner → Views session list → Sees "2/5 signatures"
→ Clicks session → Views participant status
Participant list shows:
- user1@example.com: SIGNED ✓
- user2@example.com: SIGNED ✓
- user3@example.com: VIEWED (pending)
- user4@example.com: PENDING
- user5@example.com: DECLINED ✗
Auto-refresh every 15 seconds
```
**Badge Colors:**
- 🔵 Blue: 0/5 signatures (awaiting)
- 🟡 Yellow: 2/5 signatures (partial)
- 🟢 Green: 5/5 signatures (ready to finalize)
### 5. Session Finalization
```
Owner → Clicks "Finalize" → System processes signatures
Processing steps:
1. Apply wet signatures to PDF (visual overlays)
1.5. Append signature summary page (if includeSummaryPage=true)
2. Apply digital certificates in participant order
- Visual signature block suppressed when summary page is enabled
3. Store signed PDF
4. Clear wet signature metadata (GDPR compliance)
Owner downloads signed PDF
```
**Finalization Process:**
1. **Apply Wet Signatures First**
```java
for (WetSignature sig : wetSignatures) {
PDPage page = document.getPage(sig.getPage());
byte[] imageBytes = Base64.decode(sig.getData());
// Convert Y from top-left (UI) to bottom-left (PDF) coordinate system
float pdfY = page.getMediaBox().getHeight() - sig.getY() - sig.getHeight();
PDImageXObject image = PDImageXObject.createFromByteArray(document, imageBytes, "signature");
contentStream.drawImage(image, sig.getX(), pdfY, sig.getWidth(), sig.getHeight());
}
```
2. **Append Summary Page (optional, before digital signing)**
If `includeSummaryPage=true`, a new A4 page is appended showing:
- Stirling logo and "Signature Summary" title
- Document name and session owner
- Finalization timestamp
- Per-participant: name, email, status, signed timestamp, reason, location, certificate type
- Supports overflow to additional pages
This step occurs **before** digital certificate signing so signatures are not invalidated.
When a summary page is added, the visual digital signature block (`showSignature`) is suppressed — wet signatures (hand-drawn overlays) are unaffected.
3. **Apply Digital Certificates (in participant order)**
```java
for (Participant p : participants) {
if (p.status == SIGNED) {
KeyStore keystore = buildKeystore(p.certificate);
// Reason: participant override > owner default > "Document Signing"
// Location: participant-provided only (no default)
CertSignController.sign(pdfBytes, keystore, password, settings);
}
}
```
4. **Store and Cleanup**
```java
StoredFile signedFile = storeFile(signedPdfBytes, SIGNING_SIGNED);
session.setProcessedFile(signedFile);
session.setFinalized(true);
// GDPR: Clear sensitive metadata after finalization
for (Participant p : participants) {
p.metadata.remove("wetSignatures"); // Clears wet signature image data
p.metadata.remove("certificateSubmission"); // Clears keystore bytes + password
}
```
**API Call:**
```bash
POST /api/v1/security/cert-sign/sessions/{sessionId}/finalize
Authorization: Bearer {owner_token}
```
**Response:** Binary PDF file with Content-Disposition header
## Key Technical Features
### 1. Double JSON Encoding Fix (Recent)
**Problem:** JSONB columns were storing JSON strings instead of JSON objects, requiring double-parsing.
**Solution:** Created `JsonMapConverter` JPA AttributeConverter:
```java
@Convert(converter = JsonMapConverter.class)
@Column(name = "participant_metadata", columnDefinition = "jsonb")
private Map<String, Object> participantMetadata;
```
**Benefits:**
- Single parse on read
- Proper JSON storage in PostgreSQL
- Type-safe Map access
- Backward compatible with legacy data
### 2. Signature Progress Display (Recent)
**Implementation:**
- `WorkflowSessionResponse` includes `participantCount` and `signedCount`
- `WorkflowMapper` calculates counts when converting to DTO
- Frontend displays "X/Y signatures" in session list
- Auto-refresh every 15 seconds keeps counts updated
### 3. Token-Based Security
**No Authentication Required for Participants:**
- Participants access via secure token (UUID)
- Token linked to specific participant and session
- Automatic expiration support
- One-time signing (cannot sign twice)
**Authenticated Participant Access:**
- Registered users can also access sign requests via `/api/v1/security/cert-sign/sign-requests`
- Standard Spring Security authentication required
- Supports additional cert types: UPLOAD, PEM, PKCS12, PFX
**Automatic Role Downgrade:**
- After signing: EDITOR → VIEWER
- After declining: EDITOR → VIEWER
- Prevents modification after action taken
### 4. Storage Integration
**Unified with File Sharing:**
- All PDFs stored via `StorageProvider` (Database or Local)
- Respects storage quotas
- Supports files up to 100GB+ (with Local storage)
- Consistent with existing file sharing infrastructure
### 5. Certificate Types
**P12/PKCS12/PFX:** User uploads PKCS#12 file + password
**JKS:** User uploads Java KeyStore + password
**PEM/UPLOAD:** User uploads PEM certificate + private key
**SERVER:** Uses organization's server certificate (no upload needed)
**USER_CERT:** Uses user's auto-generated personal certificate (one-click)
Note: UPLOAD, PEM, PKCS12, PFX are available on the authenticated (`sign-requests`) path. The token-based path uses P12, JKS, SERVER, USER_CERT.
## Frontend Components Overview
### Owner Workflow Components
1. **CreateSessionPanel** - Form to create new signing session
2. **ActiveSessionsPanel** - List of pending sessions with progress
3. **SessionDetailWorkbenchView** - Full session management interface
4. **CompletedSessionsPanel** - History of finalized sessions
### Participant Workflow Components
1. **SignRequestWorkbenchView** - Main signing interface
2. **SignatureSettingsInput** - Certificate selection and configuration
3. **WetSignatureInput** - Draw/type/upload signature overlay
4. **SignatureSettingsDisplay** - Preview of signature settings
### Shared Components
1. **UserSelector** - Multi-select user picker for participants
2. **LocalEmbedPDFWithAnnotations** - PDF viewer with signature placement
## Configuration
### Backend Configuration
**application.properties:**
```properties
# Database (H2 or PostgreSQL)
spring.jpa.hibernate.ddl-auto=update
# Security
DOCKER_ENABLE_SECURITY=true
# Storage Provider (DATABASE or LOCAL)
storage.provider=LOCAL
storage.maxFileSize=100GB
```
### Frontend Configuration
**Quick Access Bar:**
- Signing popout accessible from top navigation
- Auto-refresh interval: 15 seconds
- Badge shows pending session count
## API Reference Summary
### Owner Endpoints (Authenticated)
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/v1/security/cert-sign/sessions` | Create session |
| GET | `/api/v1/security/cert-sign/sessions` | List sessions |
| GET | `/api/v1/security/cert-sign/sessions/{id}` | Get details |
| POST | `/api/v1/security/cert-sign/sessions/{id}/finalize` | Finalize session |
| GET | `/api/v1/security/cert-sign/sessions/{id}/pdf` | Download original |
| GET | `/api/v1/security/cert-sign/sessions/{id}/signed-pdf` | Download signed |
| DELETE | `/api/v1/security/cert-sign/sessions/{id}` | Delete session |
| POST | `/api/v1/security/cert-sign/sessions/{id}/participants` | Add participants |
| DELETE | `/api/v1/security/cert-sign/sessions/{id}/participants/{pid}` | Remove participant |
### Authenticated Participant Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/security/cert-sign/sign-requests` | List sign requests |
| GET | `/api/v1/security/cert-sign/sign-requests/{id}` | Get sign request details |
| GET | `/api/v1/security/cert-sign/sign-requests/{id}/document` | Download document |
| POST | `/api/v1/security/cert-sign/sign-requests/{id}/sign` | Sign document |
| POST | `/api/v1/security/cert-sign/sign-requests/{id}/decline` | Decline signing |
### Token-Based Participant Endpoints (No Auth Required)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/workflow/participant/session?token={token}` | View session |
| GET | `/api/v1/workflow/participant/details?token={token}` | Get participant details |
| GET | `/api/v1/workflow/participant/document?token={token}` | Download PDF |
| POST | `/api/v1/workflow/participant/submit-signature` | Submit signature |
| POST | `/api/v1/workflow/participant/decline?token={token}` | Decline signing |
## Security Considerations
### Data Protection
- Wet signature image data cleared after finalization (GDPR compliance)
- Certificate submission data (keystore bytes + password) cleared after finalization (GDPR compliance)
- Certificate passwords are not encrypted at rest while stored (TODO: encrypt at rest)
- Token expiration support
### Access Control
- Owner authentication required for session management
- Participant access via secure UUID tokens (no auth) or standard auth (sign-requests)
- Automatic role downgrade prevents re-signing
- Session status checks prevent unauthorized actions
### Audit Trail
- All participant actions tracked
- FileShare access logged
- Status transitions recorded
- Notification history maintained
## Performance Characteristics
### Scalability
- Supports PDFs up to 100GB+ (with Local storage provider)
- Memory-efficient streaming for large files
- IndexedDB caching on frontend
- Database indexes on session_id, share_token, workflow_session_id
### Response Times
- Session creation: ~500ms (10MB file)
- Session listing: ~100ms
- Token validation: ~50ms
- Finalization: ~2s per MB of PDF (varies by certificate operations)
## Future Enhancements
### Planned Features
- Email notifications for participants
- Reminder system for pending signatures
- Bulk signing operations
- Template-based signing workflows
- Signature validation/verification UI
- Certificate password encryption at rest
- Certificate keystore cleanup after finalization (GDPR)
- Webhook support for external integrations
- Analytics dashboard for signing metrics
### Additional Workflow Types
- **REVIEW** - Document review with comments
- **APPROVAL** - Multi-level approval chains
- **COLLABORATION** - Real-time collaborative editing
## Troubleshooting
### Common Issues
**"Token invalid" error:**
- Check token exists in workflow_participants table
- Verify session is not finalized
- Check expiration date (expires_at)
**Signature not appearing on PDF:**
- Verify certificate type is correct
- Check certificate password
- Review logs for signing errors
- Ensure PDFDocumentFactory is available
**"Awaiting signatures" not updating:**
- Backend should return participantCount and signedCount
- Frontend auto-refresh every 15 seconds
- Check network tab for API errors
**Wet signatures not visible after finalization:**
- Wet signatures are applied first as image overlays (Step 1)
- Check `wetSignaturesData` was sent as valid JSON array
- Verify page index is within document bounds
- Note: wet signatures survive regardless of `includeSummaryPage` setting
### Debug Queries
```sql
-- Check session status
SELECT session_id, status, finalized,
(SELECT COUNT(*) FROM workflow_participants WHERE workflow_session_id = ws.id) as participant_count,
(SELECT COUNT(*) FROM workflow_participants WHERE workflow_session_id = ws.id AND status = 'SIGNED') as signed_count
FROM workflow_sessions ws;
-- Check participant tokens
SELECT email, status, share_token, expires_at
FROM workflow_participants
WHERE workflow_session_id = (SELECT id FROM workflow_sessions WHERE session_id = '{session_id}');
-- Check metadata storage
SELECT email,
participant_metadata->'certificateSubmission'->>'certType' as cert_type,
jsonb_array_length(participant_metadata->'wetSignatures') as wet_sig_count
FROM workflow_participants;
```
## Summary
The Shared Signing feature provides a complete collaborative signing workflow with:
- ✅ Multi-participant support with progress tracking
- ✅ Multiple certificate types (P12/PKCS12/PFX, JKS, PEM, SERVER, USER_CERT)
- ✅ Visual wet signature overlays (multiple per participant)
- ✅ Token-based security for unauthenticated participants
- ✅ Authenticated participant access via sign-requests API
- ✅ Automatic role management
- ✅ Large file support (100GB+)
- ✅ GDPR-compliant wet signature metadata cleanup
- ✅ Real-time progress updates
- ✅ Full frontend integration with Quick Access Bar
- ✅ Optional signature summary page with logo and participant details
The architecture leverages existing file sharing infrastructure while adding workflow-specific features, ensuring consistency and maintainability across the application.
@@ -356,7 +356,6 @@ public class EndpointConfiguration {
addEndpointToGroup("Security", "cert-sign");
addEndpointToGroup("Security", "remove-cert-sign");
addEndpointToGroup("Security", "sanitize-pdf");
addEndpointToGroup("Security", "timestamp-pdf");
addEndpointToGroup("Security", "auto-redact");
addEndpointToGroup("Security", "validate-signature");
addEndpointToGroup("Security", "add-stamp");
@@ -473,7 +472,6 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "auto-rename");
addEndpointToGroup("Java", "auto-split-pdf");
addEndpointToGroup("Java", "sanitize-pdf");
addEndpointToGroup("Java", "timestamp-pdf");
addEndpointToGroup("Java", "crop");
addEndpointToGroup("Java", "get-info-on-pdf");
addEndpointToGroup("Java", "pdf-to-single-page");
@@ -58,7 +58,6 @@ public class ApplicationProperties {
private Legal legal = new Legal();
private Security security = new Security();
private System system = new System();
private Storage storage = new Storage();
private Ui ui = new Ui();
private Endpoints endpoints = new Endpoints();
private Metrics metrics = new Metrics();
@@ -151,44 +150,6 @@ public class ApplicationProperties {
@Data
public static class AutoPipeline {
private String outputFolder;
private FileReadiness fileReadiness = new FileReadiness();
/**
* Configuration for the {@link stirling.software.common.util.FileReadinessChecker}.
* Controls how the pipeline determines whether a file is fully written and stable before
* processing begins.
*/
@Data
public static class FileReadiness {
/**
* Master toggle. When {@code false} every readiness check is skipped and all files are
* considered immediately ready (preserves legacy behaviour).
*/
private boolean enabled = true;
/**
* How long (in milliseconds) a file must remain unmodified before it is considered
* stable. Files modified more recently than this threshold are skipped and retried on
* the next scan cycle. Default: 5 000 ms (5 seconds).
*/
private long settleTimeMillis = 5000;
/**
* How long (in milliseconds) to pause between two consecutive file-size reads when
* checking whether a file is still being written. If the size differs between the two
* reads the file is considered unstable. This catches active copies on Linux/macOS
* where advisory locking alone cannot detect a mid-copy file. Default: 500 ms.
*/
private long sizeCheckDelayMillis = 500;
/**
* Optional list of file extensions (without the leading dot, case-insensitive) that are
* allowed through the readiness check. An empty list means all extensions are accepted.
* Example: {@code ["pdf", "tiff"]} will skip any file whose extension is not {@code
* pdf} or {@code tiff}.
*/
private List<String> allowedExtensions = new java.util.ArrayList<>();
}
}
@Data
@@ -252,7 +213,6 @@ public class ApplicationProperties {
private String customGlobalAPIKey;
private Jwt jwt = new Jwt();
private Validation validation = new Validation();
private Timestamp timestamp = new Timestamp();
private String xFrameOptions = "DENY";
public Boolean isAltLogin() {
@@ -571,12 +531,6 @@ public class ApplicationProperties {
private boolean hardFail = false;
}
}
@Data
public static class Timestamp {
private String defaultTsaUrl = "http://timestamp.digicert.com";
private List<String> customTsaUrls = new ArrayList<>();
}
}
@Data
@@ -635,41 +589,6 @@ public class ApplicationProperties {
}
}
@Data
public static class Storage {
private boolean enabled = false;
private String provider = "local";
private Local local = new Local();
private Quotas quotas = new Quotas();
private Sharing sharing = new Sharing();
private Signing signing = new Signing();
@Data
public static class Local {
private String basePath = InstallationPathConfig.getPath() + "storage";
}
@Data
public static class Sharing {
private boolean enabled = false;
private boolean linkEnabled = false;
private boolean emailEnabled = false;
private int linkExpirationDays = 3;
}
@Data
public static class Quotas {
private long maxStorageMbPerUser = -1;
private long maxStorageMbTotal = -1;
private long maxFileMb = -1;
}
@Data
public static class Signing {
private boolean enabled = false;
}
}
@Data
public static class DatabaseBackup {
private String cron = "0 0 0 * * ?"; // daily at midnight
@@ -777,7 +696,8 @@ public class ApplicationProperties {
@Override
public String toString() {
return """
return
"""
Driver {
driverName='%s'
}
@@ -1,16 +0,0 @@
package stirling.software.common.model.api.security;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserSummaryDTO {
private Long userId;
private String username;
private String displayName;
private String teamName;
private boolean enabled;
}
@@ -1,37 +0,0 @@
package stirling.software.common.service;
import java.security.KeyStore;
/**
* Abstraction for PDF digital signature operations. Defined in common so that proprietary services
* can use it without creating a circular dependency on core.
*/
public interface PdfSigningService {
/**
* Signs a PDF document using the provided KeyStore.
*
* @param pdfBytes raw PDF bytes to sign
* @param keystore the KeyStore containing the signing key and certificate chain
* @param password keystore password
* @param showSignature whether to render a visible signature block
* @param pageNumber 0-indexed page on which to render the visible signature (may be null)
* @param name signer name embedded in the signature
* @param location location string embedded in the signature
* @param reason reason string embedded in the signature
* @param showLogo whether to include the Stirling-PDF logo in the visible signature
* @return signed PDF bytes
* @throws Exception on any signing failure
*/
byte[] signWithKeystore(
byte[] pdfBytes,
KeyStore keystore,
char[] password,
boolean showSignature,
Integer pageNumber,
String name,
String location,
String reason,
boolean showLogo)
throws Exception;
}
@@ -341,7 +341,8 @@ public class EmlProcessingUtils {
}
private String getFallbackStyles() {
return """
return
"""
/* Minimal fallback - main CSS resource failed to load */
body {
font-family: var(--font-family, Helvetica, sans-serif);
@@ -23,8 +23,7 @@ public class EmlToPdf {
EmlParser.EmailContent emailContent =
EmlParser.extractEmailContent(emlBytes, request, customHtmlSanitizer);
return EmlProcessingUtils.generateEnhancedEmailHtml(
emailContent, request, customHtmlSanitizer);
return EmlProcessingUtils.generateEnhancedEmailHtml(emailContent, request, customHtmlSanitizer);
}
public static byte[] convertEmlToPdf(
@@ -1,217 +0,0 @@
package stirling.software.common.util;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.AutoPipeline.FileReadiness;
/**
* Stateless safety checker that decides whether a file is stable and ready for pipeline processing.
* Call {@link #isReady(Path)} before moving or processing any file picked up from a watched folder.
*
* <p>A file is considered ready when ALL of the following hold:
*
* <ol>
* <li>The file exists on disk.
* <li>The path refers to a regular file, not a directory.
* <li>The file's extension matches the configured allow-list (if one is set).
* <li>The file has not been modified within the configured settle window ({@code
* settleTimeMillis}), meaning it is no longer being written.
* <li>The file size is stable: two reads separated by {@code sizeCheckDelayMillis} return the
* same value. This catches active copies on Linux/macOS where advisory file locking alone
* cannot detect a mid-copy file.
* <li>An exclusive file-system lock can be acquired, confirming no other process holds it.
* </ol>
*
* <p>All behaviour is controlled through {@link FileReadiness} inside {@link
* ApplicationProperties.AutoPipeline}. Setting {@code enabled: false} makes every call return
* {@code true} so the checker is a no-op drop-in.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class FileReadinessChecker {
private final ApplicationProperties applicationProperties;
/**
* Returns {@code true} when the file at {@code path} passes every readiness check and is safe
* to hand off to the pipeline for processing. Returns {@code false} when any check fails; the
* caller should skip the file and retry on the next scan cycle.
*/
public boolean isReady(Path path) {
FileReadiness config = applicationProperties.getAutoPipeline().getFileReadiness();
if (!config.isEnabled()) {
return true;
}
if (!existsAsRegularFile(path)) {
return false;
}
if (!isExtensionAllowed(path, config.getAllowedExtensions())) {
return false;
}
if (!hasSettled(path, config.getSettleTimeMillis())) {
return false;
}
if (!hasSizeStabilized(path, config.getSizeCheckDelayMillis())) {
return false;
}
if (isLocked(path)) {
return false;
}
return true;
}
// -------------------------------------------------------------------------
// Individual checks
// -------------------------------------------------------------------------
private boolean existsAsRegularFile(Path path) {
if (!Files.exists(path)) {
log.debug("File does not exist, skipping: {}", path);
return false;
}
if (!Files.isRegularFile(path)) {
log.debug("Path is not a regular file (directory or symlink?), skipping: {}", path);
return false;
}
return true;
}
/**
* Returns {@code true} when {@code allowedExtensions} is empty (no filter) or when the file's
* extension (case-insensitive) appears in the list.
*/
private boolean isExtensionAllowed(Path path, List<String> allowedExtensions) {
if (allowedExtensions == null || allowedExtensions.isEmpty()) {
return true;
}
String filename = path.getFileName().toString();
String extension =
filename.contains(".")
? filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT)
: "";
boolean allowed =
allowedExtensions.stream().anyMatch(ext -> ext.equalsIgnoreCase(extension));
if (!allowed) {
log.debug(
"File '{}' has extension '{}' which is not in the allowed list {}, skipping",
filename,
extension,
allowedExtensions);
}
return allowed;
}
/**
* Returns {@code true} when the file's last-modified timestamp is at least {@code
* settleTimeMillis} milliseconds in the past, indicating the write has completed and the file
* has "settled".
*/
private boolean hasSettled(Path path, long settleTimeMillis) {
try {
long lastModified = Files.getLastModifiedTime(path).toMillis();
long ageMillis = System.currentTimeMillis() - lastModified;
boolean settled = ageMillis >= settleTimeMillis;
if (!settled) {
log.debug(
"File '{}' was modified {}ms ago (settle threshold: {}ms), not yet ready",
path.getFileName(),
ageMillis,
settleTimeMillis);
}
return settled;
} catch (IOException e) {
log.warn(
"Could not read last-modified time for '{}', treating as not settled: {}",
path,
e.getMessage());
return false;
}
}
/**
* Returns {@code true} when the file size is the same before and after a short pause of {@code
* sizeCheckDelayMillis} milliseconds. A size change indicates another process is still
* appending to the file. This is the primary write-detection mechanism on Linux/macOS, where
* mandatory file locking is not enforced by the OS.
*/
private boolean hasSizeStabilized(Path path, long sizeCheckDelayMillis) {
try {
long sizeBefore = Files.size(path);
Thread.sleep(sizeCheckDelayMillis);
long sizeAfter = Files.size(path);
boolean stable = sizeBefore == sizeAfter;
if (!stable) {
log.debug(
"File '{}' size changed from {} to {} bytes during stability check,"
+ " not yet ready",
path.getFileName(),
sizeBefore,
sizeAfter);
}
return stable;
} catch (IOException e) {
log.warn(
"Could not read file size for '{}', treating as unstable: {}",
path,
e.getMessage());
return false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn(
"Size stability check interrupted for '{}', treating as unstable",
path.getFileName());
return false;
}
}
/**
* Returns {@code true} when an exclusive file-system lock cannot be acquired, which indicates
* another process still holds the file open for writing.
*
* <p>{@link OverlappingFileLockException} is also treated as locked: the JVM already holds a
* lock on this file (e.g. from another thread), so it is unsafe to process.
*/
private boolean isLocked(Path path) {
try (RandomAccessFile raf = new RandomAccessFile(path.toFile(), "rw");
FileChannel channel = raf.getChannel()) {
FileLock lock = channel.tryLock();
if (lock == null) {
log.debug("File '{}' is locked by another process", path.getFileName());
return true;
}
lock.release();
return false;
} catch (OverlappingFileLockException e) {
log.debug("File '{}' is already locked by this JVM", path.getFileName());
return true;
} catch (IOException e) {
log.debug(
"Could not acquire lock on '{}', treating as locked: {}",
path.getFileName(),
e.getMessage());
return true;
}
}
}
@@ -86,6 +86,7 @@ public class RequestUriUtils {
// Blocklist of backend/non-frontend paths that should still go through filters
String[] backendOnlyPrefixes = {
"/register",
"/invite",
"/pipeline",
"/pdfjs",
"/pdfjs-legacy",
@@ -180,9 +181,7 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/readiness")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|| trimmedUri.startsWith("/v1/api-docs")
// Workflow participant endpoints — access controlled by share tokens, not login
|| trimmedUri.startsWith("/api/v1/workflow/participant/");
|| trimmedUri.startsWith("/v1/api-docs");
}
private static String stripContextPath(String contextPath, String requestURI) {
@@ -1,56 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.ApplicationArguments;
class AppArgsCaptureTest {
private AppArgsCapture capture;
@BeforeEach
void setUp() {
capture = new AppArgsCapture();
AppArgsCapture.APP_ARGS.set(List.of());
}
@Test
void run_withArgs_capturesArgs() {
ApplicationArguments args = mock(ApplicationArguments.class);
when(args.getSourceArgs()).thenReturn(new String[] {"--server.port=8080", "--debug"});
capture.run(args);
assertEquals(List.of("--server.port=8080", "--debug"), AppArgsCapture.APP_ARGS.get());
}
@Test
void run_withNoArgs_capturesEmptyList() {
ApplicationArguments args = mock(ApplicationArguments.class);
when(args.getSourceArgs()).thenReturn(new String[] {});
capture.run(args);
assertEquals(List.of(), AppArgsCapture.APP_ARGS.get());
}
@Test
void run_calledTwice_overwritesPreviousArgs() {
ApplicationArguments args1 = mock(ApplicationArguments.class);
when(args1.getSourceArgs()).thenReturn(new String[] {"--first"});
capture.run(args1);
assertEquals(List.of("--first"), AppArgsCapture.APP_ARGS.get());
ApplicationArguments args2 = mock(ApplicationArguments.class);
when(args2.getSourceArgs()).thenReturn(new String[] {"--second", "--third"});
capture.run(args2);
assertEquals(List.of("--second", "--third"), AppArgsCapture.APP_ARGS.get());
}
@Test
void appArgs_defaultValue_isEmptyList() {
// After setUp resets it
assertTrue(AppArgsCapture.APP_ARGS.get().isEmpty());
}
}
@@ -1,108 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.context.ApplicationContext;
class ApplicationContextProviderTest {
private ApplicationContextProvider provider;
@BeforeEach
void setUp() {
provider = new ApplicationContextProvider();
// Reset to null state
provider.setApplicationContext(null);
}
@AfterEach
void tearDown() {
// Clean up static state
provider.setApplicationContext(null);
}
@Test
void getBean_byClass_whenNoContext_returnsNull() {
provider.setApplicationContext(null);
assertNull(ApplicationContextProvider.getBean(String.class));
}
@Test
void getBean_byClass_whenBeanExists_returnsBean() {
ApplicationContext ctx = mock(ApplicationContext.class);
when(ctx.getBean(String.class)).thenReturn("hello");
provider.setApplicationContext(ctx);
assertEquals("hello", ApplicationContextProvider.getBean(String.class));
}
@Test
void getBean_byClass_whenBeanNotFound_returnsNull() {
ApplicationContext ctx = mock(ApplicationContext.class);
when(ctx.getBean(String.class)).thenThrow(new NoSuchBeanDefinitionException(""));
provider.setApplicationContext(ctx);
assertNull(ApplicationContextProvider.getBean(String.class));
}
@Test
void getBean_byNameAndClass_whenNoContext_returnsNull() {
provider.setApplicationContext(null);
assertNull(ApplicationContextProvider.getBean("myBean", String.class));
}
@Test
void getBean_byNameAndClass_whenBeanExists_returnsBean() {
ApplicationContext ctx = mock(ApplicationContext.class);
when(ctx.getBean("myBean", String.class)).thenReturn("world");
provider.setApplicationContext(ctx);
assertEquals("world", ApplicationContextProvider.getBean("myBean", String.class));
}
@Test
void getBean_byNameAndClass_whenBeanNotFound_returnsNull() {
ApplicationContext ctx = mock(ApplicationContext.class);
when(ctx.getBean("missing", String.class)).thenThrow(new NoSuchBeanDefinitionException(""));
provider.setApplicationContext(ctx);
assertNull(ApplicationContextProvider.getBean("missing", String.class));
}
@Test
void containsBean_whenNoContext_returnsFalse() {
provider.setApplicationContext(null);
assertFalse(ApplicationContextProvider.containsBean(String.class));
}
@Test
void containsBean_whenBeanExists_returnsTrue() {
ApplicationContext ctx = mock(ApplicationContext.class);
when(ctx.getBean(String.class)).thenReturn("exists");
provider.setApplicationContext(ctx);
assertTrue(ApplicationContextProvider.containsBean(String.class));
}
@Test
void containsBean_whenBeanNotFound_returnsFalse() {
ApplicationContext ctx = mock(ApplicationContext.class);
when(ctx.getBean(Integer.class)).thenThrow(new NoSuchBeanDefinitionException(""));
provider.setApplicationContext(ctx);
assertFalse(ApplicationContextProvider.containsBean(Integer.class));
}
@Test
void setApplicationContext_updatesStaticContext() {
ApplicationContext ctx = mock(ApplicationContext.class);
when(ctx.getBean(String.class)).thenReturn("test");
provider.setApplicationContext(ctx);
assertEquals("test", ApplicationContextProvider.getBean(String.class));
// Now set a different context
ApplicationContext ctx2 = mock(ApplicationContext.class);
when(ctx2.getBean(String.class)).thenReturn("updated");
provider.setApplicationContext(ctx2);
assertEquals("updated", ApplicationContextProvider.getBean(String.class));
}
}
@@ -1,70 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PageMode;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class AttachmentUtilsTest {
@Test
@DisplayName("should set page mode on catalog")
void setsPageMode() {
try (PDDocument document = new PDDocument()) {
AttachmentUtils.setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS);
PDDocumentCatalog catalog = document.getDocumentCatalog();
assertEquals(PageMode.USE_ATTACHMENTS, catalog.getPageMode());
} catch (Exception e) {
fail("Should not throw: " + e.getMessage());
}
}
@Test
@DisplayName("should create viewer preferences dictionary if absent")
void createsViewerPreferences() {
try (PDDocument document = new PDDocument()) {
AttachmentUtils.setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS);
COSDictionary catalogDict = document.getDocumentCatalog().getCOSObject();
COSDictionary viewerPrefs =
(COSDictionary) catalogDict.getDictionaryObject(COSName.VIEWER_PREFERENCES);
assertNotNull(viewerPrefs);
} catch (Exception e) {
fail("Should not throw: " + e.getMessage());
}
}
@Test
@DisplayName("should set DisplayDocTitle to true in viewer preferences")
void setsDisplayDocTitle() {
try (PDDocument document = new PDDocument()) {
AttachmentUtils.setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS);
COSDictionary catalogDict = document.getDocumentCatalog().getCOSObject();
COSDictionary viewerPrefs =
(COSDictionary) catalogDict.getDictionaryObject(COSName.VIEWER_PREFERENCES);
assertTrue(viewerPrefs.getBoolean(COSName.getPDFName("DisplayDocTitle"), false));
} catch (Exception e) {
fail("Should not throw: " + e.getMessage());
}
}
@Test
@DisplayName("should not throw when catalog returns null from mocked document")
void handlesNullCatalogGracefully() {
PDDocument document = mock(PDDocument.class);
when(document.getDocumentCatalog()).thenReturn(null);
assertDoesNotThrow(
() ->
AttachmentUtils.setCatalogViewerPreferences(
document, PageMode.USE_ATTACHMENTS));
}
}
@@ -1,98 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
import org.springframework.web.multipart.MultipartFile;
class CbrUtilsTest {
// --- isCbrFile tests ---
@Test
void isCbrFile_withCbrExtension_returnsTrue() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("comic.cbr");
assertTrue(CbrUtils.isCbrFile(file));
}
@Test
void isCbrFile_withRarExtension_returnsTrue() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("archive.rar");
assertTrue(CbrUtils.isCbrFile(file));
}
@Test
void isCbrFile_withUpperCaseExtension_returnsTrue() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("comic.CBR");
assertTrue(CbrUtils.isCbrFile(file));
}
@Test
void isCbrFile_withPdfExtension_returnsFalse() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("document.pdf");
assertFalse(CbrUtils.isCbrFile(file));
}
@Test
void isCbrFile_withNullFilename_returnsFalse() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn(null);
assertFalse(CbrUtils.isCbrFile(file));
}
@Test
void isCbrFile_withCbzExtension_returnsFalse() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("comic.cbz");
assertFalse(CbrUtils.isCbrFile(file));
}
@Test
void isCbrFile_withNoExtension_returnsFalse() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("noextension");
assertFalse(CbrUtils.isCbrFile(file));
}
@Test
void isCbrFile_withMixedCaseRar_returnsTrue() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("file.RaR");
assertTrue(CbrUtils.isCbrFile(file));
}
// --- convertCbrToPdf validation tests ---
@Test
void convertCbrToPdf_withNullFile_throwsException() {
assertThrows(Exception.class, () -> CbrUtils.convertCbrToPdf(null, null, null));
}
@Test
void convertCbrToPdf_withEmptyFile_throwsException() {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(true);
assertThrows(Exception.class, () -> CbrUtils.convertCbrToPdf(file, null, null));
}
@Test
void convertCbrToPdf_withNullFilename_throwsException() {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getOriginalFilename()).thenReturn(null);
assertThrows(Exception.class, () -> CbrUtils.convertCbrToPdf(file, null, null));
}
@Test
void convertCbrToPdf_withWrongExtension_throwsException() {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getOriginalFilename()).thenReturn("file.pdf");
assertThrows(Exception.class, () -> CbrUtils.convertCbrToPdf(file, null, null));
}
}
@@ -1,142 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
import org.springframework.web.multipart.MultipartFile;
class CbzUtilsTest {
// --- isCbzFile tests ---
@Test
void isCbzFile_withCbzExtension_returnsTrue() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("comic.cbz");
assertTrue(CbzUtils.isCbzFile(file));
}
@Test
void isCbzFile_withZipExtension_returnsTrue() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("archive.zip");
assertTrue(CbzUtils.isCbzFile(file));
}
@Test
void isCbzFile_withUpperCaseExtension_returnsTrue() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("comic.CBZ");
assertTrue(CbzUtils.isCbzFile(file));
}
@Test
void isCbzFile_withPdfExtension_returnsFalse() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("document.pdf");
assertFalse(CbzUtils.isCbzFile(file));
}
@Test
void isCbzFile_withNullFilename_returnsFalse() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn(null);
assertFalse(CbzUtils.isCbzFile(file));
}
@Test
void isCbzFile_withCbrExtension_returnsFalse() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("comic.cbr");
assertFalse(CbzUtils.isCbzFile(file));
}
// --- isComicBookFile tests ---
@Test
void isComicBookFile_withCbzExtension_returnsTrue() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("comic.cbz");
assertTrue(CbzUtils.isComicBookFile(file));
}
@Test
void isComicBookFile_withZipExtension_returnsTrue() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("archive.zip");
assertTrue(CbzUtils.isComicBookFile(file));
}
@Test
void isComicBookFile_withCbrExtension_returnsTrue() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("comic.cbr");
assertTrue(CbzUtils.isComicBookFile(file));
}
@Test
void isComicBookFile_withRarExtension_returnsTrue() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("archive.rar");
assertTrue(CbzUtils.isComicBookFile(file));
}
@Test
void isComicBookFile_withPdfExtension_returnsFalse() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("document.pdf");
assertFalse(CbzUtils.isComicBookFile(file));
}
@Test
void isComicBookFile_withNullFilename_returnsFalse() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn(null);
assertFalse(CbzUtils.isComicBookFile(file));
}
@Test
void isComicBookFile_withUpperCaseCBR_returnsTrue() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("comic.CBR");
assertTrue(CbzUtils.isComicBookFile(file));
}
@Test
void isComicBookFile_withNoExtension_returnsFalse() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("noextension");
assertFalse(CbzUtils.isComicBookFile(file));
}
// --- convertCbzToPdf validation tests ---
@Test
void convertCbzToPdf_withNullFile_throwsException() {
assertThrows(Exception.class, () -> CbzUtils.convertCbzToPdf(null, null, null, false));
}
@Test
void convertCbzToPdf_withEmptyFile_throwsException() {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(true);
assertThrows(Exception.class, () -> CbzUtils.convertCbzToPdf(file, null, null, false));
}
@Test
void convertCbzToPdf_withNullFilename_throwsException() {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getOriginalFilename()).thenReturn(null);
assertThrows(Exception.class, () -> CbzUtils.convertCbzToPdf(file, null, null, false));
}
@Test
void convertCbzToPdf_withWrongExtension_throwsException() {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getOriginalFilename()).thenReturn("file.pdf");
assertThrows(Exception.class, () -> CbzUtils.convertCbzToPdf(file, null, null, false));
}
}
@@ -1,210 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
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.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChecksumUtilsAdditionalTest {
private static final byte[] HELLO = "hello".getBytes(StandardCharsets.UTF_8);
@TempDir Path tempDir;
private Path writeFile(byte[] data) throws IOException {
Path file = tempDir.resolve("testfile.bin");
Files.write(file, data);
return file;
}
// --- checksum(Path, String) ---
@Test
void testChecksumPath_sha256() throws IOException {
Path file = writeFile(HELLO);
String hex = ChecksumUtils.checksum(file, "SHA-256");
assertEquals("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", hex);
}
@Test
void testChecksumPath_md5() throws IOException {
Path file = writeFile(HELLO);
String hex = ChecksumUtils.checksum(file, "MD5");
assertEquals("5d41402abc4b2a76b9719d911017c592", hex);
}
@Test
void testChecksumPath_crc32() throws IOException {
Path file = writeFile(HELLO);
String hex = ChecksumUtils.checksum(file, "CRC32");
assertEquals("3610a686", hex);
}
// --- checksum(InputStream, String) ---
@Test
void testChecksumStream_adler32() throws IOException {
try (InputStream is = new ByteArrayInputStream(HELLO)) {
String hex = ChecksumUtils.checksum(is, "ADLER32");
assertNotNull(hex);
assertEquals(8, hex.length());
}
}
@Test
void testChecksumStream_sha1() throws IOException {
try (InputStream is = new ByteArrayInputStream(HELLO)) {
String hex = ChecksumUtils.checksum(is, "SHA-1");
assertEquals("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d", hex);
}
}
@Test
void testChecksumStream_unsupportedAlgorithm() {
assertThrows(
IllegalStateException.class,
() -> {
try (InputStream is = new ByteArrayInputStream(HELLO)) {
ChecksumUtils.checksum(is, "FAKE-ALGO");
}
});
}
// --- checksumBase64(Path, String) ---
@Test
void testChecksumBase64Path_md5() throws IOException {
Path file = writeFile(HELLO);
String b64 = ChecksumUtils.checksumBase64(file, "MD5");
assertEquals("XUFAKrxLKna5cZ2REBfFkg==", b64);
}
@Test
void testChecksumBase64Path_crc32() throws IOException {
Path file = writeFile(HELLO);
String b64 = ChecksumUtils.checksumBase64(file, "CRC32");
assertEquals("NhCmhg==", b64);
}
// --- checksumBase64(InputStream, String) ---
@Test
void testChecksumBase64Stream_adler32() throws IOException {
try (InputStream is = new ByteArrayInputStream(HELLO)) {
String b64 = ChecksumUtils.checksumBase64(is, "ADLER32");
assertNotNull(b64);
assertFalse(b64.isEmpty());
}
}
@Test
void testChecksumBase64Stream_sha256() throws IOException {
try (InputStream is = new ByteArrayInputStream(HELLO)) {
String b64 = ChecksumUtils.checksumBase64(is, "SHA-256");
assertNotNull(b64);
assertFalse(b64.isEmpty());
}
}
// --- checksums(Path, String...) ---
@Test
void testChecksumsPath_multipleAlgorithms() throws IOException {
Path file = writeFile(HELLO);
Map<String, String> results = ChecksumUtils.checksums(file, "MD5", "SHA-256", "CRC32");
assertEquals(3, results.size());
assertEquals("5d41402abc4b2a76b9719d911017c592", results.get("MD5"));
assertEquals(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
results.get("SHA-256"));
assertEquals("3610a686", results.get("CRC32"));
}
@Test
void testChecksumsPath_preservesOrder() throws IOException {
Path file = writeFile(HELLO);
// Digests are output first, then Checksums (CRC32/ADLER32), per implementation
Map<String, String> results = ChecksumUtils.checksums(file, "MD5", "SHA-1");
String[] keys = results.keySet().toArray(new String[0]);
assertEquals("MD5", keys[0]);
assertEquals("SHA-1", keys[1]);
}
@Test
void testChecksumsStream_unsupportedAlgorithm() {
assertThrows(
IllegalStateException.class,
() -> {
try (InputStream is = new ByteArrayInputStream(HELLO)) {
ChecksumUtils.checksums(is, "BOGUS");
}
});
}
// --- matches(Path, String, String) ---
@Test
void testMatchesPath_correctHash() throws IOException {
Path file = writeFile(HELLO);
assertTrue(ChecksumUtils.matches(file, "MD5", "5d41402abc4b2a76b9719d911017c592"));
}
@Test
void testMatchesPath_wrongHash() throws IOException {
Path file = writeFile(HELLO);
assertFalse(ChecksumUtils.matches(file, "MD5", "0000000000000000000000000000000000"));
}
@Test
void testMatchesPath_caseInsensitive() throws IOException {
Path file = writeFile(HELLO);
assertTrue(ChecksumUtils.matches(file, "MD5", "5D41402ABC4B2A76B9719D911017C592"));
}
// --- matches(InputStream, String, String) ---
@Test
void testMatchesStream_correct() throws IOException {
try (InputStream is = new ByteArrayInputStream(HELLO)) {
assertTrue(
ChecksumUtils.matches(is, "SHA-1", "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"));
}
}
@Test
void testMatchesStream_wrong() throws IOException {
try (InputStream is = new ByteArrayInputStream(HELLO)) {
assertFalse(
ChecksumUtils.matches(is, "SHA-1", "0000000000000000000000000000000000000000"));
}
}
// --- empty input ---
@Test
void testChecksumEmptyInput() throws IOException {
byte[] empty = new byte[0];
try (InputStream is = new ByteArrayInputStream(empty)) {
String hex = ChecksumUtils.checksum(is, "MD5");
// MD5 of empty input is d41d8cd98f00b204e9800998ecf8427e
assertEquals("d41d8cd98f00b204e9800998ecf8427e", hex);
}
}
@Test
void testChecksumCrc32EmptyInput() throws IOException {
byte[] empty = new byte[0];
try (InputStream is = new ByteArrayInputStream(empty)) {
String hex = ChecksumUtils.checksum(is, "CRC32");
assertEquals("00000000", hex);
}
}
}
@@ -1,93 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
class EmlParserTest {
@Nested
@DisplayName("safeMimeDecode")
class SafeMimeDecodeTests {
@Test
@DisplayName("should return empty string for null input")
void nullInput() {
assertEquals("", EmlParser.safeMimeDecode(null));
}
@Test
@DisplayName("should return empty string for empty input")
void emptyInput() {
assertEquals("", EmlParser.safeMimeDecode(""));
}
@Test
@DisplayName("should return empty string for blank input")
void blankInput() {
assertEquals("", EmlParser.safeMimeDecode(" "));
}
@Test
@DisplayName("should return plain text as-is")
void plainText() {
assertEquals("Hello World", EmlParser.safeMimeDecode("Hello World"));
}
@Test
@DisplayName("should trim surrounding whitespace")
void trimWhitespace() {
assertEquals("Hello", EmlParser.safeMimeDecode(" Hello "));
}
@Test
@DisplayName("should decode base64 MIME encoded word")
void decodeBase64MimeWord() {
// =?UTF-8?B?SGVsbG8=?= is Base64 for "Hello"
assertEquals("Hello", EmlParser.safeMimeDecode("=?UTF-8?B?SGVsbG8=?="));
}
@Test
@DisplayName("should decode quoted-printable MIME encoded word")
void decodeQpMimeWord() {
// =?UTF-8?Q?Hello_World?= where _ means space in Q encoding
assertEquals("Hello World", EmlParser.safeMimeDecode("=?UTF-8?Q?Hello_World?="));
}
@Test
@DisplayName("should handle mixed text and encoded words")
void mixedTextAndEncoded() {
String input = "Re: =?UTF-8?B?SGVsbG8=?= test";
String result = EmlParser.safeMimeDecode(input);
assertEquals("Re: Hello test", result);
}
}
@Nested
@DisplayName("extractEmailContent")
class ExtractEmailContentTests {
@Test
@DisplayName("should throw on null input")
void nullInput() {
assertThrows(Exception.class, () -> EmlParser.extractEmailContent(null, null, null));
}
@Test
@DisplayName("should throw on empty input")
void emptyInput() {
assertThrows(
Exception.class, () -> EmlParser.extractEmailContent(new byte[0], null, null));
}
@Test
@DisplayName("should throw on invalid content that is not EML or MSG")
void invalidContent() {
byte[] randomBytes = "This is not an email file at all.".getBytes();
assertThrows(
Exception.class, () -> EmlParser.extractEmailContent(randomBytes, null, null));
}
}
}
@@ -1,293 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
class EmlProcessingUtilsTest {
@Nested
@DisplayName("validateEmlInput")
class ValidateEmlInputTests {
@Test
@DisplayName("should throw on null input")
void nullInput() {
assertThrows(Exception.class, () -> EmlProcessingUtils.validateEmlInput(null));
}
@Test
@DisplayName("should throw on empty input")
void emptyInput() {
assertThrows(Exception.class, () -> EmlProcessingUtils.validateEmlInput(new byte[0]));
}
@Test
@DisplayName("should throw on invalid format with insufficient headers")
void invalidFormat() {
byte[] data = "Hello, this is just random text without email headers.".getBytes();
assertThrows(Exception.class, () -> EmlProcessingUtils.validateEmlInput(data));
}
@Test
@DisplayName("should accept valid EML with multiple headers")
void validEml() {
String emlContent =
"From: sender@example.com\r\n"
+ "To: recipient@example.com\r\n"
+ "Subject: Test\r\n"
+ "Date: Mon, 1 Jan 2024 00:00:00 +0000\r\n"
+ "\r\n"
+ "Body text";
assertDoesNotThrow(() -> EmlProcessingUtils.validateEmlInput(emlContent.getBytes()));
}
}
@Nested
@DisplayName("isMsgFile")
class IsMsgFileTests {
@Test
@DisplayName("should return false for null")
void nullInput() {
assertFalse(EmlProcessingUtils.isMsgFile(null));
}
@Test
@DisplayName("should return false for short bytes")
void shortBytes() {
assertFalse(EmlProcessingUtils.isMsgFile(new byte[] {0x01, 0x02}));
}
@Test
@DisplayName("should return true for MSG magic bytes")
void msgMagicBytes() {
byte[] magic = {
(byte) 0xD0,
(byte) 0xCF,
(byte) 0x11,
(byte) 0xE0,
(byte) 0xA1,
(byte) 0xB1,
(byte) 0x1A,
(byte) 0xE1,
0x00,
0x00
};
assertTrue(EmlProcessingUtils.isMsgFile(magic));
}
@Test
@DisplayName("should return false for non-MSG bytes")
void nonMsgBytes() {
byte[] data = new byte[] {0x50, 0x4B, 0x03, 0x04, 0x00, 0x00, 0x00, 0x00};
assertFalse(EmlProcessingUtils.isMsgFile(data));
}
}
@Nested
@DisplayName("escapeHtml")
class EscapeHtmlTests {
@Test
@DisplayName("should return empty string for null")
void nullInput() {
assertEquals("", EmlProcessingUtils.escapeHtml(null));
}
@Test
@DisplayName("should escape all HTML special characters")
void escapeSpecialChars() {
String result = EmlProcessingUtils.escapeHtml("<div class=\"test\">'&'</div>");
assertEquals("&lt;div class=&quot;test&quot;&gt;&#39;&amp;&#39;&lt;/div&gt;", result);
}
@Test
@DisplayName("should not modify plain text")
void plainText() {
assertEquals("Hello World", EmlProcessingUtils.escapeHtml("Hello World"));
}
}
@Nested
@DisplayName("convertTextToHtml")
class ConvertTextToHtmlTests {
@Test
@DisplayName("should return empty string for null")
void nullInput() {
assertEquals("", EmlProcessingUtils.convertTextToHtml(null, null));
}
@Test
@DisplayName("should convert newlines to br tags")
void newlinesToBr() {
String result = EmlProcessingUtils.convertTextToHtml("Line1\nLine2", null);
assertTrue(result.contains("<br>"));
}
@Test
@DisplayName("should convert CRLF to br tags")
void crlfToBr() {
String result = EmlProcessingUtils.convertTextToHtml("Line1\r\nLine2", null);
assertTrue(result.contains("<br>"));
assertFalse(result.contains("\r"));
}
@Test
@DisplayName("should linkify URLs")
void linkifyUrls() {
String result =
EmlProcessingUtils.convertTextToHtml("Visit https://example.com today", null);
assertTrue(result.contains("<a href=\"https://example.com\""));
}
@Test
@DisplayName("should linkify email addresses")
void linkifyEmails() {
String result = EmlProcessingUtils.convertTextToHtml("Contact test@example.com", null);
assertTrue(result.contains("mailto:test@example.com"));
}
}
@Nested
@DisplayName("decodeMimeHeader")
class DecodeMimeHeaderTests {
@Test
@DisplayName("should return null for null input")
void nullInput() {
assertNull(EmlProcessingUtils.decodeMimeHeader(null));
}
@Test
@DisplayName("should return empty string for empty input")
void emptyInput() {
assertEquals("", EmlProcessingUtils.decodeMimeHeader(""));
}
@Test
@DisplayName("should return plain text unchanged")
void plainText() {
assertEquals("Hello World", EmlProcessingUtils.decodeMimeHeader("Hello World"));
}
@Test
@DisplayName("should decode Base64 encoded header")
void decodeBase64() {
// "Hello" in Base64
String result = EmlProcessingUtils.decodeMimeHeader("=?UTF-8?B?SGVsbG8=?=");
assertEquals("Hello", result);
}
@Test
@DisplayName("should decode quoted-printable encoded header")
void decodeQuotedPrintable() {
String result = EmlProcessingUtils.decodeMimeHeader("=?UTF-8?Q?Hello_World?=");
assertEquals("Hello World", result);
}
@Test
@DisplayName("should decode concatenated encoded words")
void decodeConcatenated() {
String input = "=?UTF-8?B?SGVs?= =?UTF-8?B?bG8=?=";
String result = EmlProcessingUtils.decodeMimeHeader(input);
assertEquals("Hello", result);
}
@Test
@DisplayName("should handle unknown encoding gracefully")
void unknownEncoding() {
String input = "=?UTF-8?X?unknown?=";
String result = EmlProcessingUtils.decodeMimeHeader(input);
assertEquals("=?UTF-8?X?unknown?=", result);
}
}
@Nested
@DisplayName("detectMimeType")
class DetectMimeTypeTests {
@Test
@DisplayName("should return existing MIME type if provided")
void existingMimeType() {
assertEquals(
"image/jpeg", EmlProcessingUtils.detectMimeType("photo.png", "image/jpeg"));
}
@Test
@DisplayName("should detect PNG from filename")
void detectPng() {
assertEquals("image/png", EmlProcessingUtils.detectMimeType("image.png", null));
}
@Test
@DisplayName("should detect JPEG from filename")
void detectJpeg() {
assertEquals("image/jpeg", EmlProcessingUtils.detectMimeType("photo.jpg", null));
}
@Test
@DisplayName("should default to image/png for unknown extension")
void defaultMimeType() {
assertEquals("image/png", EmlProcessingUtils.detectMimeType("file.xyz", null));
}
@Test
@DisplayName("should default to image/png for null filename and mime")
void nullFilenameAndMime() {
assertEquals("image/png", EmlProcessingUtils.detectMimeType(null, null));
}
}
@Nested
@DisplayName("sanitizeText")
class SanitizeTextTests {
@Test
@DisplayName("should escape HTML when no sanitizer provided")
void noSanitizer() {
String result = EmlProcessingUtils.sanitizeText("<script>", null);
assertEquals("&lt;script&gt;", result);
}
}
@Nested
@DisplayName("processEmailHtmlBody")
class ProcessEmailHtmlBodyTests {
@Test
@DisplayName("should return empty string for null body")
void nullBody() {
assertEquals("", EmlProcessingUtils.processEmailHtmlBody(null, null, null));
}
@Test
@DisplayName("should strip fixed position CSS")
void stripFixedPosition() {
String html = "<div style=\"position:fixed; top:0\">content</div>";
String result = EmlProcessingUtils.processEmailHtmlBody(html, null, null);
assertFalse(result.contains("position:fixed"));
}
}
@Nested
@DisplayName("decodeUrlEncoded")
class DecodeUrlEncodedTests {
@Test
@DisplayName("should decode URL-encoded string")
void decodeEncoded() {
assertEquals("hello world", EmlProcessingUtils.decodeUrlEncoded("hello%20world"));
}
@Test
@DisplayName("should return original on invalid encoding")
void invalidEncoding() {
String result = EmlProcessingUtils.decodeUrlEncoded("%ZZinvalid");
assertEquals("%ZZinvalid", result);
}
}
}
@@ -1,114 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.ui.Model;
import org.springframework.web.servlet.ModelAndView;
class ErrorUtilsTest {
@Nested
@DisplayName("exceptionToModel")
class ExceptionToModelTests {
@Test
@DisplayName("should add error message to model")
void addsErrorMessage() {
Model model = mock(Model.class);
Exception ex = new RuntimeException("test error");
ErrorUtils.exceptionToModel(model, ex);
verify(model).addAttribute("errorMessage", "test error");
}
@Test
@DisplayName("should add stack trace to model")
void addsStackTrace() {
Model model = mock(Model.class);
Exception ex = new RuntimeException("test error");
ErrorUtils.exceptionToModel(model, ex);
verify(model)
.addAttribute(
eq("stackTrace"),
argThat(
arg ->
arg instanceof String s
&& s.contains("RuntimeException")
&& s.contains("test error")));
}
@Test
@DisplayName("should return the same model instance")
void returnsSameModel() {
Model model = mock(Model.class);
Exception ex = new RuntimeException("test");
Model result = ErrorUtils.exceptionToModel(model, ex);
assertSame(model, result);
}
@Test
@DisplayName("should handle exception with null message")
void nullExceptionMessage() {
Model model = mock(Model.class);
Exception ex = new RuntimeException((String) null);
ErrorUtils.exceptionToModel(model, ex);
verify(model).addAttribute("errorMessage", null);
}
}
@Nested
@DisplayName("exceptionToModelView")
class ExceptionToModelViewTests {
@Test
@DisplayName("should create ModelAndView with error message")
void addsErrorMessage() {
Model model = mock(Model.class);
Exception ex = new RuntimeException("view error");
ModelAndView result = ErrorUtils.exceptionToModelView(model, ex);
assertNotNull(result);
assertEquals("view error", result.getModel().get("errorMessage"));
}
@Test
@DisplayName("should create ModelAndView with stack trace")
void addsStackTrace() {
Model model = mock(Model.class);
Exception ex = new RuntimeException("view error");
ModelAndView result = ErrorUtils.exceptionToModelView(model, ex);
String stackTrace = (String) result.getModel().get("stackTrace");
assertNotNull(stackTrace);
assertTrue(stackTrace.contains("RuntimeException"));
assertTrue(stackTrace.contains("view error"));
}
@Test
@DisplayName("should handle nested exception")
void nestedException() {
Model model = mock(Model.class);
Exception cause = new IllegalArgumentException("root cause");
Exception ex = new RuntimeException("wrapper", cause);
ModelAndView result = ErrorUtils.exceptionToModelView(model, ex);
String stackTrace = (String) result.getModel().get("stackTrace");
assertTrue(stackTrace.contains("root cause"));
assertEquals("wrapper", result.getModel().get("errorMessage"));
}
}
}
@@ -1,90 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class ExecutorFactoryTest {
@Test
@DisplayName("newVirtualThreadExecutor should return non-null executor")
void virtualThreadExecutorNotNull() {
ExecutorService executor = ExecutorFactory.newVirtualThreadExecutor();
assertNotNull(executor);
executor.shutdown();
}
@Test
@DisplayName("newVirtualThreadExecutor should execute tasks")
void virtualThreadExecutorExecutesTasks() throws Exception {
ExecutorService executor = ExecutorFactory.newVirtualThreadExecutor();
AtomicBoolean ran = new AtomicBoolean(false);
CountDownLatch latch = new CountDownLatch(1);
executor.submit(
() -> {
ran.set(true);
latch.countDown();
});
assertTrue(latch.await(5, TimeUnit.SECONDS));
assertTrue(ran.get());
executor.shutdown();
}
@Test
@DisplayName("newVirtualThreadExecutor should run on virtual threads")
void virtualThreadExecutorUsesVirtualThreads() throws Exception {
ExecutorService executor = ExecutorFactory.newVirtualThreadExecutor();
AtomicReference<Boolean> isVirtual = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);
executor.submit(
() -> {
isVirtual.set(Thread.currentThread().isVirtual());
latch.countDown();
});
assertTrue(latch.await(5, TimeUnit.SECONDS));
assertTrue(isVirtual.get());
executor.shutdown();
}
@Test
@DisplayName("newSingleVirtualThreadScheduledExecutor should return non-null")
void scheduledExecutorNotNull() {
ScheduledExecutorService executor =
ExecutorFactory.newSingleVirtualThreadScheduledExecutor();
assertNotNull(executor);
executor.shutdown();
}
@Test
@DisplayName("newSingleVirtualThreadScheduledExecutor should execute scheduled tasks")
void scheduledExecutorExecutesTasks() throws Exception {
ScheduledExecutorService executor =
ExecutorFactory.newSingleVirtualThreadScheduledExecutor();
AtomicBoolean ran = new AtomicBoolean(false);
CountDownLatch latch = new CountDownLatch(1);
executor.schedule(
() -> {
ran.set(true);
latch.countDown();
},
10,
TimeUnit.MILLISECONDS);
assertTrue(latch.await(5, TimeUnit.SECONDS));
assertTrue(ran.get());
executor.shutdown();
}
}
@@ -1,110 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.function.Predicate;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import stirling.software.common.configuration.RuntimePathConfig;
class FileMonitorTest {
@TempDir Path tempDir;
private FileMonitor createFileMonitor(Path watchDir) throws IOException {
Predicate<Path> acceptAll = path -> true;
RuntimePathConfig runtimePathConfig = mock(RuntimePathConfig.class);
when(runtimePathConfig.getPipelineWatchedFoldersPaths())
.thenReturn(List.of(watchDir.toString()));
return new FileMonitor(acceptAll, runtimePathConfig);
}
@Test
void testConstructor_withValidDirectory() throws IOException {
FileMonitor monitor = createFileMonitor(tempDir);
assertNotNull(monitor);
}
@Test
void testConstructor_withNonExistentDirectory() throws IOException {
Path nonExistent = tempDir.resolve("does_not_exist");
Predicate<Path> acceptAll = path -> true;
RuntimePathConfig config = mock(RuntimePathConfig.class);
when(config.getPipelineWatchedFoldersPaths()).thenReturn(List.of(nonExistent.toString()));
// Should not throw - just logs an error about non-existent path
FileMonitor monitor = new FileMonitor(acceptAll, config);
assertNotNull(monitor);
}
@Test
void testConstructor_withEmptyWatchedFolders() throws IOException {
Predicate<Path> acceptAll = path -> true;
RuntimePathConfig config = mock(RuntimePathConfig.class);
when(config.getPipelineWatchedFoldersPaths()).thenReturn(List.of());
FileMonitor monitor = new FileMonitor(acceptAll, config);
assertNotNull(monitor);
}
@Test
void testTrackFiles_noEventsDoesNotThrow() throws IOException {
FileMonitor monitor = createFileMonitor(tempDir);
// Should not throw even when no events have occurred
assertDoesNotThrow(() -> monitor.trackFiles());
}
@Test
void testIsFileReadyForProcessing_nonExistentFile() throws IOException {
FileMonitor monitor = createFileMonitor(tempDir);
Path nonExistent = tempDir.resolve("nonexistent.pdf");
// Non-existent file should not be ready (file lock check will fail)
boolean ready = monitor.isFileReadyForProcessing(nonExistent);
assertFalse(ready, "Non-existent file should not be ready for processing");
}
@Test
void testIsFileReadyForProcessing_existingFile() throws IOException, InterruptedException {
FileMonitor monitor = createFileMonitor(tempDir);
Path testFile = tempDir.resolve("test.pdf");
Files.writeString(testFile, "test content");
// Run trackFiles to process any events
monitor.trackFiles();
// The file might or might not be ready depending on timing,
// but calling the method should not throw
assertDoesNotThrow(() -> monitor.isFileReadyForProcessing(testFile));
}
@Test
void testTrackFiles_afterFileCreation() throws IOException {
FileMonitor monitor = createFileMonitor(tempDir);
// Create a file in the watched directory
Path testFile = tempDir.resolve("newfile.txt");
Files.writeString(testFile, "hello");
// Track files should process the creation event
assertDoesNotThrow(() -> monitor.trackFiles());
}
@Test
void testConstructor_withPathFilter() throws IOException {
// Filter that rejects all paths
Predicate<Path> rejectAll = path -> false;
RuntimePathConfig config = mock(RuntimePathConfig.class);
when(config.getPipelineWatchedFoldersPaths()).thenReturn(List.of(tempDir.toString()));
FileMonitor monitor = new FileMonitor(rejectAll, config);
assertNotNull(monitor);
}
}
@@ -1,357 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Answers.CALLS_REAL_METHODS;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
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.Mock;
import org.mockito.MockedStatic;
import org.mockito.MockitoAnnotations;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.AutoPipeline.FileReadiness;
@DisplayName("FileReadinessChecker")
class FileReadinessCheckerTest {
@TempDir Path tempDir;
@Mock ApplicationProperties applicationProperties;
@Mock ApplicationProperties.AutoPipeline autoPipeline;
/** Real config object — easier to tweak per test than chaining multiple stubs. */
FileReadiness config;
FileReadinessChecker checker;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
config = new FileReadiness();
config.setEnabled(true);
config.setSettleTimeMillis(0); // instant settle by default — individual tests override
config.setSizeCheckDelayMillis(1); // minimal pause keeps tests fast
config.setAllowedExtensions(new ArrayList<>());
when(applicationProperties.getAutoPipeline()).thenReturn(autoPipeline);
when(autoPipeline.getFileReadiness()).thenReturn(config);
checker = new FileReadinessChecker(applicationProperties);
}
// =========================================================================
// Master toggle
// =========================================================================
@Nested
@DisplayName("when enabled=false")
class WhenDisabled {
@Test
@DisplayName("always returns true regardless of file state")
void alwaysReady() throws IOException {
config.setEnabled(false);
// Non-existent path — would normally fail check #1
Path ghost = tempDir.resolve("does-not-exist.pdf");
assertTrue(checker.isReady(ghost));
}
}
// =========================================================================
// Check #1 + #2: existence and regular-file guard
// =========================================================================
@Nested
@DisplayName("existence and file-type checks")
class ExistenceChecks {
@Test
@DisplayName("non-existent path → not ready")
void fileDoesNotExist() {
Path ghost = tempDir.resolve("ghost.pdf");
assertFalse(checker.isReady(ghost));
}
@Test
@DisplayName("path is a directory → not ready")
void pathIsDirectory() throws IOException {
Path dir = tempDir.resolve("subdir");
Files.createDirectory(dir);
assertFalse(checker.isReady(dir));
}
@Test
@DisplayName("path is a regular file → passes existence checks")
void regularFilePassesExistenceCheck() throws IOException {
Path file = realFile("test.pdf", "content");
setLastModifiedInPast(file, 60_000);
assertTrue(checker.isReady(file));
}
}
// =========================================================================
// Check #3: extension filter
// =========================================================================
@Nested
@DisplayName("extension filter")
class ExtensionFilter {
@Test
@DisplayName("empty allow-list → all extensions accepted")
void emptyAllowListAcceptsAll() throws IOException {
config.setAllowedExtensions(new ArrayList<>()); // empty = no filter
Path file = realFile("report.docx", "data");
setLastModifiedInPast(file, 60_000);
assertTrue(checker.isReady(file));
}
@Test
@DisplayName("extension in allow-list → passes")
void extensionInAllowList() throws IOException {
config.setAllowedExtensions(List.of("pdf", "tiff"));
Path file = realFile("scan.pdf", "data");
setLastModifiedInPast(file, 60_000);
assertTrue(checker.isReady(file));
}
@Test
@DisplayName("extension not in allow-list → not ready")
void extensionNotInAllowList() throws IOException {
config.setAllowedExtensions(List.of("pdf", "tiff"));
Path file = realFile("document.docx", "data");
setLastModifiedInPast(file, 60_000);
assertFalse(checker.isReady(file));
}
@Test
@DisplayName("extension matching is case-insensitive")
void extensionMatchIsCaseInsensitive() throws IOException {
config.setAllowedExtensions(List.of("PDF"));
Path file = realFile("scan.pdf", "data");
setLastModifiedInPast(file, 60_000);
assertTrue(checker.isReady(file));
}
@Test
@DisplayName("file without extension and non-empty allow-list → not ready")
void fileWithNoExtension() throws IOException {
config.setAllowedExtensions(List.of("pdf"));
Path file = realFile("README", "data");
setLastModifiedInPast(file, 60_000);
assertFalse(checker.isReady(file));
}
}
// =========================================================================
// Check #4: settle-time (last-modified age)
// =========================================================================
@Nested
@DisplayName("settle-time check")
class SettleTime {
@Test
@DisplayName("recently modified file → not ready")
void recentlyModified_notReady() throws IOException {
config.setSettleTimeMillis(60_000); // require 1 minute of quiet
Path file = realFile("new.pdf", "data");
// last-modified is now (just created) — well within the threshold
assertFalse(checker.isReady(file));
}
@Test
@DisplayName("file settled for longer than threshold → ready")
void settled_ready() throws IOException {
config.setSettleTimeMillis(5_000);
Path file = realFile("old.pdf", "data");
setLastModifiedInPast(file, 10_000); // 10 s ago — older than 5 s threshold
assertTrue(checker.isReady(file));
}
@Test
@DisplayName("settle threshold of 0 ms passes any file")
void zeroThreshold_alwaysPasses() throws IOException {
config.setSettleTimeMillis(0);
Path file = realFile("instant.pdf", "data");
// last-modified is right now; 0 ms threshold means anything passes
assertTrue(checker.isReady(file));
}
}
// =========================================================================
// Check #5: size stability
// =========================================================================
@Nested
@DisplayName("size-stability check")
class SizeStability {
@Test
@DisplayName("size unchanged between two reads → ready")
void sizeStable_ready() throws IOException {
config.setSizeCheckDelayMillis(1);
Path file = realFile("stable.pdf", "fixed content");
setLastModifiedInPast(file, 60_000);
assertTrue(checker.isReady(file));
}
@Test
@DisplayName("size changes between two reads → not ready")
void sizeChanging_notReady() throws IOException {
config.setSizeCheckDelayMillis(1);
Path file = realFile("growing.pdf", "initial");
setLastModifiedInPast(file, 60_000);
// Use MockedStatic to control what Files.size() returns on each call
// while leaving all other Files.* methods intact.
AtomicInteger sizeCallCount = new AtomicInteger(0);
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class, CALLS_REAL_METHODS)) {
mockedFiles
.when(() -> Files.size(file))
.thenAnswer(
inv ->
sizeCallCount.incrementAndGet() == 1
? 100L // first read: 100 bytes
: 200L); // second read: 200 bytes — changed!
assertFalse(checker.isReady(file));
}
}
}
// =========================================================================
// Check #6: file-lock check
// =========================================================================
@Nested
@DisplayName("file-lock check")
class FileLockCheck {
@Test
@DisplayName("file held open with exclusive lock by another thread → not ready")
void fileLocked_notReady() throws IOException, InterruptedException {
Path file = realFile("locked.pdf", "data");
setLastModifiedInPast(file, 60_000);
CountDownLatch lockAcquired = new CountDownLatch(1);
CountDownLatch testDone = new CountDownLatch(1);
AtomicInteger lockThreadFailed = new AtomicInteger(0);
Thread lockHolder =
new Thread(
() -> {
try (RandomAccessFile raf =
new RandomAccessFile(file.toFile(), "rw");
FileChannel channel = raf.getChannel();
FileLock lock = channel.lock()) {
lockAcquired.countDown();
testDone.await(10, TimeUnit.SECONDS);
} catch (Exception e) {
lockThreadFailed.set(1);
lockAcquired.countDown();
}
});
lockHolder.setDaemon(true);
lockHolder.start();
lockAcquired.await(5, TimeUnit.SECONDS);
try {
if (lockThreadFailed.get() == 0) {
// Lock was successfully held — the checker must see it as locked.
// On JVM, tryLock() from a second thread in the same process throws
// OverlappingFileLockException (or returns null on some platforms), both of
// which isLocked() maps to true.
assertFalse(checker.isReady(file));
}
// If locking failed on this platform we simply skip the assertion rather than
// failing the build — the logic path is still exercised by other tests.
} finally {
testDone.countDown();
lockHolder.join(5_000);
}
}
@Test
@DisplayName("file with no external lock and all checks passing → ready")
void noLock_ready() throws IOException {
Path file = realFile("unlocked.pdf", "data");
setLastModifiedInPast(file, 60_000);
assertTrue(checker.isReady(file));
}
}
// =========================================================================
// Full happy-path integration
// =========================================================================
@Nested
@DisplayName("full happy path")
class HappyPath {
@Test
@DisplayName("all checks pass → ready")
void allChecksPass_ready() throws IOException {
config.setSettleTimeMillis(5_000);
config.setSizeCheckDelayMillis(1);
config.setAllowedExtensions(List.of("pdf"));
Path file = realFile("invoice.pdf", "PDF content");
setLastModifiedInPast(file, 10_000);
assertTrue(checker.isReady(file));
}
@Test
@DisplayName("first failing check short-circuits evaluation")
void shortCircuitsOnFirstFailure() throws IOException {
// Extension filter will reject — settle / size / lock checks must never run
config.setAllowedExtensions(List.of("pdf"));
config.setSettleTimeMillis(0);
config.setSizeCheckDelayMillis(1);
Path file = realFile("archive.zip", "ZIP data");
setLastModifiedInPast(file, 60_000);
assertFalse(checker.isReady(file));
}
}
// =========================================================================
// Helpers
// =========================================================================
private Path realFile(String name, String content) throws IOException {
Path file = tempDir.resolve(name);
Files.writeString(file, content);
return file;
}
/**
* Back-dates the last-modified time of {@code path} by {@code millisAgo} so that settle-time
* checks pass without actually waiting.
*/
private void setLastModifiedInPast(Path path, long millisAgo) throws IOException {
Files.setLastModifiedTime(
path, FileTime.fromMillis(System.currentTimeMillis() - millisAgo));
}
}
@@ -1,76 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class FileToPdfTest {
@Test
void testSanitizeZipFilename_normalFilename() {
String result = FileToPdf.sanitizeZipFilename("document.html");
assertEquals("document.html", result);
}
@Test
void testSanitizeZipFilename_pathTraversal() {
String result = FileToPdf.sanitizeZipFilename("../../etc/passwd");
// Should remove ../ sequences
assertFalse(result.contains(".."), "Path traversal sequences should be removed");
}
@Test
void testSanitizeZipFilename_driveLetterRemoved() {
String result = FileToPdf.sanitizeZipFilename("C:\\Users\\test\\file.html");
assertFalse(result.startsWith("C:"), "Drive letter should be removed");
}
@Test
void testSanitizeZipFilename_backslashesNormalized() {
String result = FileToPdf.sanitizeZipFilename("path\\to\\file.html");
assertFalse(result.contains("\\"), "Backslashes should be normalized to forward slashes");
assertTrue(result.contains("/") || !result.contains("\\"));
}
@Test
void testSanitizeZipFilename_nullInput() {
String result = FileToPdf.sanitizeZipFilename(null);
assertEquals("", result, "Null input should return empty string");
}
@Test
void testSanitizeZipFilename_emptyInput() {
String result = FileToPdf.sanitizeZipFilename("");
assertEquals("", result, "Empty input should return empty string");
}
@Test
void testSanitizeZipFilename_whitespaceOnly() {
String result = FileToPdf.sanitizeZipFilename(" ");
assertEquals("", result, "Whitespace-only input should return empty string");
}
@Test
void testSanitizeZipFilename_leadingSlashes() {
String result = FileToPdf.sanitizeZipFilename("///path/to/file.html");
assertFalse(result.startsWith("/"), "Leading slashes should be removed");
}
@Test
void testSanitizeZipFilename_nestedDirectories() {
String result = FileToPdf.sanitizeZipFilename("dir1/dir2/file.html");
assertEquals("dir1/dir2/file.html", result, "Normal nested paths should be preserved");
}
@Test
void testSanitizeZipFilename_mixedTraversal() {
String result = FileToPdf.sanitizeZipFilename("dir/../../../etc/passwd");
assertFalse(result.contains(".."), "Mixed path traversal should be removed");
}
@Test
void testSanitizeZipFilename_backslashTraversal() {
String result = FileToPdf.sanitizeZipFilename("dir\\..\\..\\etc\\passwd");
assertFalse(result.contains(".."), "Backslash path traversal should be removed");
}
}
@@ -1,163 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Test;
class FormFieldTypeSupportTest {
@Test
void forField_withNull_returnsNull() {
assertNull(FormFieldTypeSupport.forField(null));
}
@Test
void forField_withTextField_returnsTEXT() {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDTextField field = new PDTextField(form);
assertEquals(FormFieldTypeSupport.TEXT, FormFieldTypeSupport.forField(field));
} catch (Exception e) {
fail("Unexpected exception: " + e.getMessage());
}
}
@Test
void forField_withCheckBox_returnsCHECKBOX() {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDCheckBox field = new PDCheckBox(form);
assertEquals(FormFieldTypeSupport.CHECKBOX, FormFieldTypeSupport.forField(field));
} catch (Exception e) {
fail("Unexpected exception: " + e.getMessage());
}
}
@Test
void forField_withRadioButton_returnsRADIO() {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDRadioButton field = new PDRadioButton(form);
assertEquals(FormFieldTypeSupport.RADIO, FormFieldTypeSupport.forField(field));
} catch (Exception e) {
fail("Unexpected exception: " + e.getMessage());
}
}
@Test
void forField_withComboBox_returnsCOMBOBOX() {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDComboBox field = new PDComboBox(form);
assertEquals(FormFieldTypeSupport.COMBOBOX, FormFieldTypeSupport.forField(field));
} catch (Exception e) {
fail("Unexpected exception: " + e.getMessage());
}
}
@Test
void forField_withListBox_returnsLISTBOX() {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDListBox field = new PDListBox(form);
assertEquals(FormFieldTypeSupport.LISTBOX, FormFieldTypeSupport.forField(field));
} catch (Exception e) {
fail("Unexpected exception: " + e.getMessage());
}
}
@Test
void forField_withSignatureField_returnsSIGNATURE() {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDSignatureField field = new PDSignatureField(form);
assertEquals(FormFieldTypeSupport.SIGNATURE, FormFieldTypeSupport.forField(field));
} catch (Exception e) {
fail("Unexpected exception: " + e.getMessage());
}
}
@Test
void forField_withPushButton_returnsBUTTON() {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDPushButton field = new PDPushButton(form);
assertEquals(FormFieldTypeSupport.BUTTON, FormFieldTypeSupport.forField(field));
} catch (Exception e) {
fail("Unexpected exception: " + e.getMessage());
}
}
@Test
void forTypeName_withValidNames_returnsCorrectEnum() {
assertEquals(FormFieldTypeSupport.TEXT, FormFieldTypeSupport.forTypeName("text"));
assertEquals(FormFieldTypeSupport.CHECKBOX, FormFieldTypeSupport.forTypeName("checkbox"));
assertEquals(FormFieldTypeSupport.RADIO, FormFieldTypeSupport.forTypeName("radio"));
assertEquals(FormFieldTypeSupport.COMBOBOX, FormFieldTypeSupport.forTypeName("combobox"));
assertEquals(FormFieldTypeSupport.LISTBOX, FormFieldTypeSupport.forTypeName("listbox"));
assertEquals(FormFieldTypeSupport.SIGNATURE, FormFieldTypeSupport.forTypeName("signature"));
assertEquals(FormFieldTypeSupport.BUTTON, FormFieldTypeSupport.forTypeName("button"));
}
@Test
void forTypeName_withNull_returnsNull() {
assertNull(FormFieldTypeSupport.forTypeName(null));
}
@Test
void forTypeName_withUnknown_returnsNull() {
assertNull(FormFieldTypeSupport.forTypeName("unknown"));
}
@Test
void doesNotSupportsDefinitionCreation_textReturnsFalse() {
assertFalse(FormFieldTypeSupport.TEXT.doesNotsupportsDefinitionCreation());
}
@Test
void doesNotSupportsDefinitionCreation_radioReturnsTrue() {
assertTrue(FormFieldTypeSupport.RADIO.doesNotsupportsDefinitionCreation());
}
@Test
void doesNotSupportsDefinitionCreation_signatureReturnsTrue() {
assertTrue(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation());
}
@Test
void doesNotSupportsDefinitionCreation_buttonReturnsTrue() {
assertTrue(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation());
}
@Test
void createField_text_returnsPDTextField() {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDTerminalField field = FormFieldTypeSupport.TEXT.createField(form);
assertInstanceOf(PDTextField.class, field);
} catch (Exception e) {
fail("Unexpected exception: " + e.getMessage());
}
}
@Test
void createField_checkbox_returnsPDCheckBox() {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDTerminalField field = FormFieldTypeSupport.CHECKBOX.createField(form);
assertInstanceOf(PDCheckBox.class, field);
} catch (Exception e) {
fail("Unexpected exception: " + e.getMessage());
}
}
}
@@ -1,304 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Test;
class FormUtilsAdditionalTest {
private record SetupDocument(PDPage page, PDAcroForm acroForm) {}
private static SetupDocument createBasicDocument(PDDocument document) throws IOException {
PDPage page = new PDPage();
document.addPage(page);
PDAcroForm acroForm = new PDAcroForm(document);
acroForm.setDefaultResources(new PDResources());
acroForm.setNeedAppearances(true);
document.getDocumentCatalog().setAcroForm(acroForm);
return new SetupDocument(page, acroForm);
}
private static void attachWidget(
SetupDocument setup,
org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField field,
PDRectangle rectangle)
throws IOException {
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(rectangle);
widget.setPage(setup.page);
List<PDAnnotationWidget> widgets = new ArrayList<>(field.getWidgets());
widgets.add(widget);
field.setWidgets(widgets);
setup.acroForm.getFields().add(field);
setup.page.getAnnotations().add(widget);
}
// --- detectFieldType ---
@Test
void testDetectFieldType_textField() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField field = new PDTextField(setup.acroForm);
assertEquals("text", FormUtils.detectFieldType(field));
}
}
@Test
void testDetectFieldType_checkBox() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDCheckBox field = new PDCheckBox(setup.acroForm);
assertEquals("checkbox", FormUtils.detectFieldType(field));
}
}
// --- extractFormFields ---
@Test
void testExtractFormFields_nullDocument() {
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(null);
assertTrue(fields.isEmpty());
}
@Test
void testExtractFormFields_noAcroForm() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
// No AcroForm set
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertTrue(fields.isEmpty());
}
}
@Test
void testExtractFormFields_singleTextField() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField textField = new PDTextField(setup.acroForm);
textField.setPartialName("firstName");
attachWidget(setup, textField, new PDRectangle(50, 700, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals(1, fields.size());
assertEquals("firstName", fields.get(0).name());
assertEquals("text", fields.get(0).type());
assertEquals(0, fields.get(0).pageIndex());
}
}
@Test
void testExtractFormFields_multipleFields() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField field1 = new PDTextField(setup.acroForm);
field1.setPartialName("name");
attachWidget(setup, field1, new PDRectangle(50, 700, 200, 20));
PDTextField field2 = new PDTextField(setup.acroForm);
field2.setPartialName("email");
attachWidget(setup, field2, new PDRectangle(50, 660, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals(2, fields.size());
}
}
// --- buildFillTemplateRecord ---
@Test
void testBuildFillTemplateRecord_null() {
Map<String, Object> result = FormUtils.buildFillTemplateRecord(null);
assertTrue(result.isEmpty());
}
@Test
void testBuildFillTemplateRecord_empty() {
Map<String, Object> result = FormUtils.buildFillTemplateRecord(Collections.emptyList());
assertTrue(result.isEmpty());
}
@Test
void testBuildFillTemplateRecord_textField() {
FormUtils.FormFieldInfo info =
new FormUtils.FormFieldInfo(
"name", "Name", "text", "John", null, false, 0, false, null, 0);
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
assertEquals("John", result.get("name"));
}
@Test
void testBuildFillTemplateRecord_checkboxField() {
FormUtils.FormFieldInfo info =
new FormUtils.FormFieldInfo(
"agree", "Agreement", "checkbox", "Yes", null, false, 0, false, null, 0);
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
assertEquals(Boolean.TRUE, result.get("agree"));
}
@Test
void testBuildFillTemplateRecord_checkboxFieldOff() {
FormUtils.FormFieldInfo info =
new FormUtils.FormFieldInfo(
"agree", "Agreement", "checkbox", "Off", null, false, 0, false, null, 0);
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
assertEquals(Boolean.FALSE, result.get("agree"));
}
@Test
void testBuildFillTemplateRecord_skipsButton() {
FormUtils.FormFieldInfo info =
new FormUtils.FormFieldInfo(
"submit", "Submit", "button", null, null, false, 0, false, null, 0);
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
assertFalse(result.containsKey("submit"));
}
@Test
void testBuildFillTemplateRecord_skipsSignature() {
FormUtils.FormFieldInfo info =
new FormUtils.FormFieldInfo(
"sig", "Signature", "signature", null, null, false, 0, false, null, 0);
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
assertFalse(result.containsKey("sig"));
}
// --- safeValue ---
@Test
void testSafeValue_nonNull() {
assertEquals("hello", FormUtils.safeValue("hello"));
}
@Test
void testSafeValue_null() {
assertEquals("", FormUtils.safeValue(null));
}
// --- applyFieldValues ---
@Test
void testApplyFieldValues_nullDocument() throws IOException {
// Should not throw
FormUtils.applyFieldValues(null, Map.of("key", "value"), false);
}
@Test
void testApplyFieldValues_noAcroFormStrict() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
assertThrows(
IOException.class,
() -> FormUtils.applyFieldValues(doc, Map.of("key", "val"), false, true));
}
}
@Test
void testApplyFieldValues_noAcroFormNonStrict() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
// Should not throw in non-strict mode
FormUtils.applyFieldValues(doc, Map.of("key", "val"), false, false);
}
}
@Test
void testApplyFieldValues_setsTextValue() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField textField = new PDTextField(setup.acroForm);
textField.setPartialName("company");
attachWidget(setup, textField, new PDRectangle(60, 720, 220, 20));
FormUtils.applyFieldValues(doc, Map.of("company", "Stirling"), false);
assertEquals("Stirling", textField.getValueAsString());
}
}
@Test
void testApplyFieldValues_checksCheckbox_nonStrict() throws IOException {
// In non-strict mode, checkbox state changes may fail silently
// if appearance streams are not properly configured. Just verify no exception.
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDCheckBox checkBox = new PDCheckBox(setup.acroForm);
checkBox.setPartialName("subscribed");
checkBox.setExportValues(List.of("Yes"));
attachWidget(setup, checkBox, new PDRectangle(60, 680, 16, 16));
// Should not throw in non-strict mode even if appearance is missing
FormUtils.applyFieldValues(doc, Map.of("subscribed", true), false, false);
FormUtils.applyFieldValues(doc, Map.of("subscribed", false), false, false);
}
}
// --- filterSingleChoiceSelection ---
@Test
void testFilterSingleChoiceSelection_validSelection() {
String result =
FormUtils.filterSingleChoiceSelection(
"Option A", List.of("Option A", "Option B"), "field1");
assertEquals("Option A", result);
}
@Test
void testFilterSingleChoiceSelection_invalidSelection() {
String result =
FormUtils.filterSingleChoiceSelection(
"Invalid", List.of("Option A", "Option B"), "field1");
assertNull(result);
}
@Test
void testFilterSingleChoiceSelection_nullSelection() {
String result = FormUtils.filterSingleChoiceSelection(null, List.of("Option A"), "field1");
assertNull(result);
}
@Test
void testFilterSingleChoiceSelection_emptySelection() {
String result = FormUtils.filterSingleChoiceSelection(" ", List.of("Option A"), "field1");
assertNull(result);
}
// --- extractFieldsWithTemplate ---
@Test
void testExtractFieldsWithTemplate_emptyDocument() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
FormUtils.FormFieldExtraction extraction = FormUtils.extractFieldsWithTemplate(doc);
assertNotNull(extraction);
assertTrue(extraction.fields().isEmpty());
assertTrue(extraction.template().isEmpty());
}
}
// --- hasAnyRotatedPage ---
@Test
void testHasAnyRotatedPage_noRotation() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
assertFalse(FormUtils.hasAnyRotatedPage(doc));
}
}
}
@@ -1,97 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.Test;
class GeneralFormCopyUtilsTest {
@Test
void hasAnyRotatedPage_noRotation_returnsFalse() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
doc.addPage(new PDPage());
assertFalse(GeneralFormCopyUtils.hasAnyRotatedPage(doc));
}
}
@Test
void hasAnyRotatedPage_with90Rotation_returnsTrue() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
page.setRotation(90);
doc.addPage(page);
assertTrue(GeneralFormCopyUtils.hasAnyRotatedPage(doc));
}
}
@Test
void hasAnyRotatedPage_with180Rotation_returnsTrue() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
page.setRotation(180);
doc.addPage(page);
assertTrue(GeneralFormCopyUtils.hasAnyRotatedPage(doc));
}
}
@Test
void hasAnyRotatedPage_with360Rotation_returnsFalse() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
page.setRotation(360);
doc.addPage(page);
assertFalse(GeneralFormCopyUtils.hasAnyRotatedPage(doc));
}
}
@Test
void hasAnyRotatedPage_emptyDocument_returnsFalse() throws Exception {
try (PDDocument doc = new PDDocument()) {
assertFalse(GeneralFormCopyUtils.hasAnyRotatedPage(doc));
}
}
@Test
void hasAnyRotatedPage_mixedPages_returnsTrue() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
PDPage rotated = new PDPage();
rotated.setRotation(270);
doc.addPage(rotated);
assertTrue(GeneralFormCopyUtils.hasAnyRotatedPage(doc));
}
}
@Test
void copyAndTransformFormFields_noAcroForm_doesNotThrow() throws Exception {
try (PDDocument source = new PDDocument();
PDDocument target = new PDDocument()) {
source.addPage(new PDPage());
target.addPage(new PDPage());
// No acro form set on source - should simply return without error
assertDoesNotThrow(
() ->
GeneralFormCopyUtils.copyAndTransformFormFields(
source, target, 1, 1, 1, 1, 612f, 792f));
}
}
@Test
void copyAndTransformFormFields_emptyAcroForm_doesNotThrow() throws Exception {
try (PDDocument source = new PDDocument();
PDDocument target = new PDDocument()) {
source.addPage(new PDPage());
target.addPage(new PDPage());
// Empty acro form
var acroForm = new org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm(source);
source.getDocumentCatalog().setAcroForm(acroForm);
assertDoesNotThrow(
() ->
GeneralFormCopyUtils.copyAndTransformFormFields(
source, target, 1, 1, 1, 1, 612f, 792f));
}
}
}
@@ -1,147 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Test;
class GeneralFormFieldTypeSupportTest {
@Test
void forField_withNull_returnsNull() {
assertNull(GeneralFormFieldTypeSupport.forField(null));
}
@Test
void forField_withTextField_returnsTEXT() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDTextField field = new PDTextField(form);
assertEquals(
GeneralFormFieldTypeSupport.TEXT, GeneralFormFieldTypeSupport.forField(field));
}
}
@Test
void forField_withCheckBox_returnsCHECKBOX() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDCheckBox field = new PDCheckBox(form);
assertEquals(
GeneralFormFieldTypeSupport.CHECKBOX,
GeneralFormFieldTypeSupport.forField(field));
}
}
@Test
void forField_withRadioButton_returnsRADIO() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDRadioButton field = new PDRadioButton(form);
assertEquals(
GeneralFormFieldTypeSupport.RADIO, GeneralFormFieldTypeSupport.forField(field));
}
}
@Test
void forField_withComboBox_returnsCOMBOBOX() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDComboBox field = new PDComboBox(form);
assertEquals(
GeneralFormFieldTypeSupport.COMBOBOX,
GeneralFormFieldTypeSupport.forField(field));
}
}
@Test
void forField_withListBox_returnsLISTBOX() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDListBox field = new PDListBox(form);
assertEquals(
GeneralFormFieldTypeSupport.LISTBOX,
GeneralFormFieldTypeSupport.forField(field));
}
}
@Test
void forField_withSignatureField_returnsSIGNATURE() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDSignatureField field = new PDSignatureField(form);
assertEquals(
GeneralFormFieldTypeSupport.SIGNATURE,
GeneralFormFieldTypeSupport.forField(field));
}
}
@Test
void forField_withPushButton_returnsBUTTON() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDPushButton field = new PDPushButton(form);
assertEquals(
GeneralFormFieldTypeSupport.BUTTON,
GeneralFormFieldTypeSupport.forField(field));
}
}
@Test
void createField_text_returnsPDTextField() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDTerminalField field = GeneralFormFieldTypeSupport.TEXT.createField(form);
assertInstanceOf(PDTextField.class, field);
}
}
@Test
void createField_checkbox_returnsPDCheckBox() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDTerminalField field = GeneralFormFieldTypeSupport.CHECKBOX.createField(form);
assertInstanceOf(PDCheckBox.class, field);
}
}
@Test
void createField_signature_returnsPDSignatureField() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = new PDAcroForm(doc);
PDTerminalField field = GeneralFormFieldTypeSupport.SIGNATURE.createField(form);
assertInstanceOf(PDSignatureField.class, field);
}
}
@Test
void typeName_returnsExpectedValues() {
assertEquals("text", GeneralFormFieldTypeSupport.TEXT.typeName());
assertEquals("checkbox", GeneralFormFieldTypeSupport.CHECKBOX.typeName());
assertEquals("radio", GeneralFormFieldTypeSupport.RADIO.typeName());
assertEquals("combobox", GeneralFormFieldTypeSupport.COMBOBOX.typeName());
assertEquals("listbox", GeneralFormFieldTypeSupport.LISTBOX.typeName());
assertEquals("signature", GeneralFormFieldTypeSupport.SIGNATURE.typeName());
assertEquals("button", GeneralFormFieldTypeSupport.BUTTON.typeName());
}
@Test
void fallbackWidgetName_returnsExpectedValues() {
assertEquals("textField", GeneralFormFieldTypeSupport.TEXT.fallbackWidgetName());
assertEquals("checkBox", GeneralFormFieldTypeSupport.CHECKBOX.fallbackWidgetName());
assertEquals("radioButton", GeneralFormFieldTypeSupport.RADIO.fallbackWidgetName());
assertEquals("comboBox", GeneralFormFieldTypeSupport.COMBOBOX.fallbackWidgetName());
assertEquals("listBox", GeneralFormFieldTypeSupport.LISTBOX.fallbackWidgetName());
assertEquals("signature", GeneralFormFieldTypeSupport.SIGNATURE.fallbackWidgetName());
assertEquals("pushButton", GeneralFormFieldTypeSupport.BUTTON.fallbackWidgetName());
}
}
@@ -1,111 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferByte;
import java.awt.image.DataBufferInt;
import org.junit.jupiter.api.Test;
class ImageProcessingUtilsTest {
@Test
void convertColorType_greyscale_returnsGrayscaleImage() {
BufferedImage source = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
BufferedImage result = ImageProcessingUtils.convertColorType(source, "greyscale");
assertEquals(BufferedImage.TYPE_BYTE_GRAY, result.getType());
assertEquals(10, result.getWidth());
assertEquals(10, result.getHeight());
}
@Test
void convertColorType_blackwhite_returnsBinaryImage() {
BufferedImage source = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
BufferedImage result = ImageProcessingUtils.convertColorType(source, "blackwhite");
assertEquals(BufferedImage.TYPE_BYTE_BINARY, result.getType());
}
@Test
void convertColorType_fullColor_returnsSameImage() {
BufferedImage source = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
BufferedImage result = ImageProcessingUtils.convertColorType(source, "fullcolor");
assertSame(source, result);
}
@Test
void convertColorType_unknownType_returnsSameImage() {
BufferedImage source = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
BufferedImage result = ImageProcessingUtils.convertColorType(source, "something_else");
assertSame(source, result);
}
@Test
void getImageData_byteBuffer_returnsCorrectData() {
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_BYTE_GRAY);
byte[] data = ImageProcessingUtils.getImageData(image);
assertNotNull(data);
assertTrue(data instanceof byte[]);
// TYPE_BYTE_GRAY uses DataBufferByte
assertTrue(image.getRaster().getDataBuffer() instanceof DataBufferByte);
}
@Test
void getImageData_intBuffer_returnsCorrectLength() {
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB);
// TYPE_INT_RGB uses DataBufferInt
assertTrue(image.getRaster().getDataBuffer() instanceof DataBufferInt);
byte[] data = ImageProcessingUtils.getImageData(image);
assertNotNull(data);
// 2x2 pixels, 4 bytes per int
assertEquals(2 * 2 * 4, data.length);
}
@Test
void getImageData_ushortBuffer_returnsRGBData() {
// TYPE_USHORT_GRAY uses DataBufferUShort which hits the else branch
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_USHORT_GRAY);
byte[] data = ImageProcessingUtils.getImageData(image);
assertNotNull(data);
// 2x2 pixels, 3 bytes per pixel (RGB)
assertEquals(2 * 2 * 3, data.length);
}
@Test
void applyOrientation_zeroRotation_returnsSameImage() {
BufferedImage image = new BufferedImage(10, 20, BufferedImage.TYPE_INT_RGB);
BufferedImage result = ImageProcessingUtils.applyOrientation(image, 0);
assertSame(image, result);
}
@Test
void applyOrientation_90degrees_returnsRotatedImage() {
BufferedImage image = new BufferedImage(10, 20, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
g.setColor(Color.RED);
g.fillRect(0, 0, 10, 20);
g.dispose();
BufferedImage result = ImageProcessingUtils.applyOrientation(image, 90);
assertNotNull(result);
// The rotated image should have non-zero dimensions
assertTrue(result.getWidth() > 0);
assertTrue(result.getHeight() > 0);
}
@Test
void applyOrientation_180degrees_returnsRotatedImage() {
BufferedImage image = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
BufferedImage result = ImageProcessingUtils.applyOrientation(image, 180);
assertNotNull(result);
}
@Test
void applyOrientation_270degrees_returnsRotatedImage() {
BufferedImage image = new BufferedImage(10, 20, BufferedImage.TYPE_INT_RGB);
BufferedImage result = ImageProcessingUtils.applyOrientation(image, 270);
assertNotNull(result);
}
}
@@ -1,49 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
class JarPathUtilTest {
@Test
void currentJar_notRunningFromJar_returnsNull() {
// When running tests from IDE/Gradle, we are not in a JAR
Path result = JarPathUtil.currentJar();
assertNull(result, "Should return null when not running from a JAR file");
}
@Test
void restartHelperJar_notFound_returnsNull() {
// Since we're not running from JAR and restart-helper.jar likely doesn't exist
Path result = JarPathUtil.restartHelperJar();
assertNull(result, "Should return null when restart-helper.jar is not found");
}
@Test
void javaExecutable_returnsNonNullPath() {
String result = JarPathUtil.javaExecutable();
assertNotNull(result);
assertTrue(result.contains("java"), "Should contain 'java' in the path");
assertTrue(result.contains("bin"), "Should contain 'bin' in the path");
}
@Test
void javaExecutable_containsJavaHome() {
String javaHome = System.getProperty("java.home");
String result = JarPathUtil.javaExecutable();
assertTrue(result.startsWith(javaHome), "Should start with java.home system property");
}
@Test
void javaExecutable_windowsHasExeExtension() {
String result = JarPathUtil.javaExecutable();
if (System.getProperty("os.name").toLowerCase().contains("win")) {
assertTrue(result.endsWith(".exe"), "On Windows, should end with .exe");
} else {
assertFalse(result.endsWith(".exe"), "On non-Windows, should not end with .exe");
}
}
}
@@ -1,62 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class JobContextTest {
@AfterEach
void cleanup() {
JobContext.clear();
}
@Test
@DisplayName("should return null when no job ID is set")
void returnsNullByDefault() {
assertNull(JobContext.getJobId());
}
@Test
@DisplayName("should store and retrieve job ID")
void setAndGet() {
JobContext.setJobId("job-123");
assertEquals("job-123", JobContext.getJobId());
}
@Test
@DisplayName("should clear job ID")
void clearJobId() {
JobContext.setJobId("job-456");
JobContext.clear();
assertNull(JobContext.getJobId());
}
@Test
@DisplayName("should isolate job IDs between threads")
void threadIsolation() throws Exception {
JobContext.setJobId("main-job");
Thread other =
new Thread(
() -> {
assertNull(JobContext.getJobId());
JobContext.setJobId("other-job");
assertEquals("other-job", JobContext.getJobId());
});
other.start();
other.join();
assertEquals("main-job", JobContext.getJobId());
}
@Test
@DisplayName("should allow overwriting job ID")
void overwriteJobId() {
JobContext.setJobId("first");
JobContext.setJobId("second");
assertEquals("second", JobContext.getJobId());
}
}
@@ -1,95 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.service.CustomPDFDocumentFactory;
class PDFServiceTest {
private PDFService pdfService;
private CustomPDFDocumentFactory mockFactory;
private final List<PDDocument> documentsToClose = new ArrayList<>();
@BeforeEach
void setUp() {
mockFactory = mock(CustomPDFDocumentFactory.class);
pdfService = new PDFService(mockFactory);
}
@AfterEach
void tearDown() throws IOException {
for (PDDocument doc : documentsToClose) {
try {
doc.close();
} catch (Exception ignored) {
}
}
}
private PDDocument createDocWithPages(int pageCount) {
PDDocument doc = new PDDocument();
for (int i = 0; i < pageCount; i++) {
doc.addPage(new PDPage());
}
documentsToClose.add(doc);
return doc;
}
@Test
void mergeDocuments_twoDocuments_mergesPages() throws IOException {
PDDocument merged = new PDDocument();
documentsToClose.add(merged);
when(mockFactory.createNewDocument()).thenReturn(merged);
PDDocument doc1 = createDocWithPages(2);
PDDocument doc2 = createDocWithPages(3);
PDDocument result = pdfService.mergeDocuments(List.of(doc1, doc2));
assertEquals(5, result.getNumberOfPages());
}
@Test
void mergeDocuments_emptyList_returnsEmptyDocument() throws IOException {
PDDocument merged = new PDDocument();
documentsToClose.add(merged);
when(mockFactory.createNewDocument()).thenReturn(merged);
PDDocument result = pdfService.mergeDocuments(List.of());
assertEquals(0, result.getNumberOfPages());
}
@Test
void mergeDocuments_singleDocument_returnsSamePages() throws IOException {
PDDocument merged = new PDDocument();
documentsToClose.add(merged);
when(mockFactory.createNewDocument()).thenReturn(merged);
PDDocument doc1 = createDocWithPages(4);
PDDocument result = pdfService.mergeDocuments(List.of(doc1));
assertEquals(4, result.getNumberOfPages());
}
@Test
void mergeDocuments_factoryCalled() throws IOException {
PDDocument merged = new PDDocument();
documentsToClose.add(merged);
when(mockFactory.createNewDocument()).thenReturn(merged);
PDDocument doc1 = createDocWithPages(1);
pdfService.mergeDocuments(List.of(doc1));
verify(mockFactory).createNewDocument();
}
}
@@ -1,98 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Date;
import java.util.GregorianCalendar;
import org.junit.jupiter.api.Test;
class PdfAttachmentHandlerTest {
@Test
void formatEmailDate_nullDate_returnsEmptyString() {
assertEquals("", PdfAttachmentHandler.formatEmailDate((Date) null));
}
@Test
void formatEmailDate_nullZonedDateTime_returnsEmptyString() {
assertEquals("", PdfAttachmentHandler.formatEmailDate((ZonedDateTime) null));
}
@Test
void formatEmailDate_validDate_returnsFormattedString() {
// Create a date: January 15, 2024 10:30 AM UTC
GregorianCalendar cal = new GregorianCalendar(java.util.TimeZone.getTimeZone("UTC"));
cal.set(2024, 0, 15, 10, 30, 0);
cal.set(java.util.Calendar.MILLISECOND, 0);
Date date = cal.getTime();
String result = PdfAttachmentHandler.formatEmailDate(date);
assertNotNull(result);
assertFalse(result.isEmpty());
// Should contain the date components
assertTrue(result.contains("2024"));
assertTrue(result.contains("Jan"));
assertTrue(result.contains("15"));
}
@Test
void formatEmailDate_zonedDateTime_returnsUTCFormatted() {
ZonedDateTime dateTime =
ZonedDateTime.of(2024, 3, 15, 14, 30, 0, 0, ZoneId.of("America/New_York"));
String result = PdfAttachmentHandler.formatEmailDate(dateTime);
assertNotNull(result);
assertFalse(result.isEmpty());
// Should be converted to UTC
assertTrue(result.contains("UTC"));
assertTrue(result.contains("2024"));
}
@Test
void processInlineImages_nullHtmlContent_returnsNull() {
String result = PdfAttachmentHandler.processInlineImages(null, null);
assertNull(result);
}
@Test
void processInlineImages_nullEmailContent_returnsOriginal() {
String html = "<html><body>test</body></html>";
String result = PdfAttachmentHandler.processInlineImages(html, null);
assertEquals(html, result);
}
@Test
void processInlineImages_noCidReferences_returnsOriginal() {
EmlParser.EmailContent emailContent = new EmlParser.EmailContent();
String html = "<html><body><img src='test.png'/></body></html>";
String result = PdfAttachmentHandler.processInlineImages(html, emailContent);
assertEquals(html, result);
}
@Test
void markerPosition_constructorAndGetters() {
PdfAttachmentHandler.MarkerPosition pos =
new PdfAttachmentHandler.MarkerPosition(2, 100.5f, 200.3f, "@", "test.pdf");
assertEquals(2, pos.getPageIndex());
assertEquals(100.5f, pos.getX(), 0.001f);
assertEquals(200.3f, pos.getY(), 0.001f);
assertEquals("@", pos.getCharacter());
assertEquals("test.pdf", pos.getFilename());
}
@Test
void markerPosition_setters() {
PdfAttachmentHandler.MarkerPosition pos =
new PdfAttachmentHandler.MarkerPosition(0, 0f, 0f, "@", null);
pos.setPageIndex(5);
pos.setX(50.0f);
pos.setY(75.0f);
pos.setFilename("doc.pdf");
assertEquals(5, pos.getPageIndex());
assertEquals(50.0f, pos.getX(), 0.001f);
assertEquals(75.0f, pos.getY(), 0.001f);
assertEquals("doc.pdf", pos.getFilename());
}
}
@@ -1,85 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class PdfErrorUtilsTest {
@ParameterizedTest
@ValueSource(
strings = {
"Missing root object specification",
"Header doesn't contain versioninfo",
"Expected trailer",
"Invalid PDF",
"Corrupted",
"damaged",
"Unknown dir object",
"Can't dereference COSObject",
"parseCOSString string should start with",
"ICCBased colorspace array must have a stream",
"1-based index not found",
"Invalid dictionary, found:",
"AES initialization vector not fully read",
"BadPaddingException",
"Given final block not properly padded",
"End-of-File, expected line"
})
void isCorruptedPdfError_ioException_corruptionIndicators_returnsTrue(String message) {
IOException e = new IOException(message);
assertTrue(PdfErrorUtils.isCorruptedPdfError(e));
}
@ParameterizedTest
@ValueSource(
strings = {
"Missing root object specification in the file",
"Header doesn't contain versioninfo xyz",
"Some prefix Corrupted suffix"
})
void isCorruptedPdfError_ioException_messagesContainingIndicators_returnsTrue(String message) {
IOException e = new IOException(message);
assertTrue(PdfErrorUtils.isCorruptedPdfError(e));
}
@Test
void isCorruptedPdfError_ioException_normalError_returnsFalse() {
IOException e = new IOException("File not found");
assertFalse(PdfErrorUtils.isCorruptedPdfError(e));
}
@Test
void isCorruptedPdfError_ioException_nullMessage_returnsFalse() {
IOException e = new IOException((String) null);
assertFalse(PdfErrorUtils.isCorruptedPdfError(e));
}
@Test
void isCorruptedPdfError_genericException_corruptionMessage_returnsTrue() {
Exception e = new RuntimeException("Invalid PDF structure");
assertTrue(PdfErrorUtils.isCorruptedPdfError(e));
}
@Test
void isCorruptedPdfError_genericException_normalMessage_returnsFalse() {
Exception e = new RuntimeException("Something went wrong");
assertFalse(PdfErrorUtils.isCorruptedPdfError(e));
}
@Test
void isCorruptedPdfError_genericException_nullMessage_returnsFalse() {
Exception e = new RuntimeException((String) null);
assertFalse(PdfErrorUtils.isCorruptedPdfError(e));
}
@Test
void isCorruptedPdfError_ioException_emptyMessage_returnsFalse() {
IOException e = new IOException("");
assertFalse(PdfErrorUtils.isCorruptedPdfError(e));
}
}
@@ -1,80 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
import org.springframework.web.multipart.MultipartFile;
class PdfToCbrUtilsTest {
@Test
void isPdfFile_pdfExtension_returnsTrue() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("document.pdf");
assertTrue(PdfToCbrUtils.isPdfFile(file));
}
@Test
void isPdfFile_uppercasePdfExtension_returnsTrue() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("document.PDF");
assertTrue(PdfToCbrUtils.isPdfFile(file));
}
@Test
void isPdfFile_mixedCasePdfExtension_returnsTrue() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("document.Pdf");
assertTrue(PdfToCbrUtils.isPdfFile(file));
}
@Test
void isPdfFile_nonPdfExtension_returnsFalse() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("document.txt");
assertFalse(PdfToCbrUtils.isPdfFile(file));
}
@Test
void isPdfFile_nullFilename_returnsFalse() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn(null);
assertFalse(PdfToCbrUtils.isPdfFile(file));
}
@Test
void isPdfFile_imageExtension_returnsFalse() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("image.png");
assertFalse(PdfToCbrUtils.isPdfFile(file));
}
@Test
void convertPdfToCbr_nullFile_throwsException() {
assertThrows(Exception.class, () -> PdfToCbrUtils.convertPdfToCbr(null, 300, null));
}
@Test
void convertPdfToCbr_emptyFile_throwsException() {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(true);
assertThrows(Exception.class, () -> PdfToCbrUtils.convertPdfToCbr(file, 300, null));
}
@Test
void convertPdfToCbr_nonPdfFile_throwsException() {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getOriginalFilename()).thenReturn("image.png");
assertThrows(Exception.class, () -> PdfToCbrUtils.convertPdfToCbr(file, 300, null));
}
@Test
void convertPdfToCbr_nullFilename_throwsException() {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getOriginalFilename()).thenReturn(null);
assertThrows(Exception.class, () -> PdfToCbrUtils.convertPdfToCbr(file, 300, null));
}
}
@@ -1,73 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
import org.springframework.web.multipart.MultipartFile;
class PdfToCbzUtilsTest {
@Test
void isPdfFile_pdfExtension_returnsTrue() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("document.pdf");
assertTrue(PdfToCbzUtils.isPdfFile(file));
}
@Test
void isPdfFile_uppercasePdfExtension_returnsTrue() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("DOCUMENT.PDF");
assertTrue(PdfToCbzUtils.isPdfFile(file));
}
@Test
void isPdfFile_nonPdfExtension_returnsFalse() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("document.docx");
assertFalse(PdfToCbzUtils.isPdfFile(file));
}
@Test
void isPdfFile_nullFilename_returnsFalse() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn(null);
assertFalse(PdfToCbzUtils.isPdfFile(file));
}
@Test
void isPdfFile_noExtension_returnsFalse() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("document");
assertFalse(PdfToCbzUtils.isPdfFile(file));
}
@Test
void convertPdfToCbz_nullFile_throwsException() {
assertThrows(Exception.class, () -> PdfToCbzUtils.convertPdfToCbz(null, 300, null, null));
}
@Test
void convertPdfToCbz_emptyFile_throwsException() {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(true);
assertThrows(Exception.class, () -> PdfToCbzUtils.convertPdfToCbz(file, 300, null, null));
}
@Test
void convertPdfToCbz_nonPdfFile_throwsException() {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getOriginalFilename()).thenReturn("image.jpg");
assertThrows(Exception.class, () -> PdfToCbzUtils.convertPdfToCbz(file, 300, null, null));
}
@Test
void convertPdfToCbz_nullFilename_throwsException() {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getOriginalFilename()).thenReturn(null);
assertThrows(Exception.class, () -> PdfToCbzUtils.convertPdfToCbz(file, 300, null, null));
}
}
@@ -1,245 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import java.io.IOException;
import java.util.List;
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.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class PdfUtilsTest {
@ParameterizedTest
@CsvSource({"A0", "A1", "A2", "A3", "A4", "A5", "A6", "LETTER", "LEGAL"})
void textToPageSize_validSizes_returnsCorrectRectangle(String size) {
PDRectangle result = PdfUtils.textToPageSize(size);
assertNotNull(result);
assertTrue(result.getWidth() > 0);
assertTrue(result.getHeight() > 0);
}
@Test
void textToPageSize_lowercaseA4_returnsA4() {
PDRectangle result = PdfUtils.textToPageSize("a4");
assertEquals(PDRectangle.A4.getWidth(), result.getWidth(), 0.01f);
assertEquals(PDRectangle.A4.getHeight(), result.getHeight(), 0.01f);
}
@Test
void textToPageSize_invalidSize_throwsException() {
assertThrows(Exception.class, () -> PdfUtils.textToPageSize("INVALID"));
}
@Test
void getAllImages_emptyResources_returnsEmptyList() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
page.setResources(new PDResources());
doc.addPage(page);
List<RenderedImage> images = PdfUtils.getAllImages(page.getResources());
assertTrue(images.isEmpty());
}
}
@Test
void getAllImages_withImage_returnsImage() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
BufferedImage bufferedImage = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
PDImageXObject pdImage = LosslessFactory.createFromImage(doc, bufferedImage);
PDResources resources = new PDResources();
resources.add(pdImage);
page.setResources(resources);
List<RenderedImage> images = PdfUtils.getAllImages(page.getResources());
assertEquals(1, images.size());
}
}
@Test
void hasImagesOnPage_noImages_returnsFalse() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
page.setResources(new PDResources());
doc.addPage(page);
assertFalse(PdfUtils.hasImagesOnPage(page));
}
}
@Test
void hasTextOnPage_noText_returnsFalse() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
assertFalse(PdfUtils.hasTextOnPage(page, "hello"));
}
}
@Test
void pageCount_greaterComparator_correct() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
doc.addPage(new PDPage());
doc.addPage(new PDPage());
assertTrue(PdfUtils.pageCount(doc, 2, "greater"));
}
}
@Test
void pageCount_equalComparator_correct() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
doc.addPage(new PDPage());
assertTrue(PdfUtils.pageCount(doc, 2, "equal"));
}
}
@Test
void pageCount_lessComparator_correct() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
assertTrue(PdfUtils.pageCount(doc, 5, "less"));
}
}
@Test
void pageCount_invalidComparator_throwsException() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
assertThrows(Exception.class, () -> PdfUtils.pageCount(doc, 1, "invalid"));
}
}
@Test
void pageSize_matchingSize_returnsTrue() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
String sizeStr = PDRectangle.A4.getWidth() + "x" + PDRectangle.A4.getHeight();
assertTrue(PdfUtils.pageSize(doc, sizeStr));
}
}
@Test
void pageSize_nonMatchingSize_returnsFalse() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
assertFalse(PdfUtils.pageSize(doc, "100x100"));
}
}
// --- hasImages ---
@Test
void hasImages_noImages_returnsFalse() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
page.setResources(new PDResources());
doc.addPage(page);
assertFalse(PdfUtils.hasImages(doc, "all"));
}
}
@Test
void hasImages_withImage_returnsTrue() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
BufferedImage bufferedImage = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
PDImageXObject pdImage = LosslessFactory.createFromImage(doc, bufferedImage);
PDResources resources = new PDResources();
resources.add(pdImage);
page.setResources(resources);
assertTrue(PdfUtils.hasImages(doc, "all"));
}
}
// --- hasText ---
@Test
void hasText_noText_returnsFalse() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
assertFalse(PdfUtils.hasText(doc, "all", "hello"));
}
}
// --- textToPageSize additional ---
@Test
void textToPageSize_letter_returnsLetter() {
PDRectangle result = PdfUtils.textToPageSize("letter");
assertEquals(PDRectangle.LETTER.getWidth(), result.getWidth(), 0.01f);
assertEquals(PDRectangle.LETTER.getHeight(), result.getHeight(), 0.01f);
}
@Test
void textToPageSize_legal_returnsLegal() {
PDRectangle result = PdfUtils.textToPageSize("legal");
assertEquals(PDRectangle.LEGAL.getWidth(), result.getWidth(), 0.01f);
assertEquals(PDRectangle.LEGAL.getHeight(), result.getHeight(), 0.01f);
}
// --- pageCount additional ---
@Test
void pageCount_greaterComparator_false() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
assertFalse(PdfUtils.pageCount(doc, 5, "greater"));
}
}
@Test
void pageCount_equalComparator_false() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
doc.addPage(new PDPage());
assertFalse(PdfUtils.pageCount(doc, 3, "equal"));
}
}
@Test
void pageCount_lessComparator_false() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
doc.addPage(new PDPage());
doc.addPage(new PDPage());
assertFalse(PdfUtils.pageCount(doc, 2, "less"));
}
}
// --- hasImagesOnPage with image ---
@Test
void hasImagesOnPage_withImage_returnsTrue() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
BufferedImage bufferedImage = new BufferedImage(5, 5, BufferedImage.TYPE_INT_RGB);
PDImageXObject pdImage = LosslessFactory.createFromImage(doc, bufferedImage);
PDResources resources = new PDResources();
resources.add(pdImage);
page.setResources(resources);
assertTrue(PdfUtils.hasImagesOnPage(page));
}
}
}
@@ -1,120 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class ProcessExecutorTest {
// Use reflection to test private validateCommand method
private void invokeValidateCommand(ProcessExecutor executor, List<String> command)
throws Exception {
Method method = ProcessExecutor.class.getDeclaredMethod("validateCommand", List.class);
method.setAccessible(true);
try {
method.invoke(executor, command);
} catch (java.lang.reflect.InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
private ProcessExecutor getExecutor() {
return ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF);
}
@Test
void testValidateCommand_nullCommand() {
assertThrows(
IllegalArgumentException.class, () -> invokeValidateCommand(getExecutor(), null));
}
@Test
void testValidateCommand_emptyCommand() {
assertThrows(
IllegalArgumentException.class,
() -> invokeValidateCommand(getExecutor(), List.of()));
}
@Test
void testValidateCommand_nullArgument() {
List<String> command = new ArrayList<>();
command.add("echo");
command.add(null);
assertThrows(
IllegalArgumentException.class,
() -> invokeValidateCommand(getExecutor(), command));
}
@Test
void testValidateCommand_nullByteInArgument() {
assertThrows(
IllegalArgumentException.class,
() -> invokeValidateCommand(getExecutor(), List.of("echo", "bad\0arg")));
}
@Test
void testValidateCommand_newlineInArgument() {
assertThrows(
IllegalArgumentException.class,
() -> invokeValidateCommand(getExecutor(), List.of("echo", "bad\narg")));
}
@Test
void testValidateCommand_carriageReturnInArgument() {
assertThrows(
IllegalArgumentException.class,
() -> invokeValidateCommand(getExecutor(), List.of("echo", "bad\rarg")));
}
@Test
void testValidateCommand_pathTraversal() {
assertThrows(
IllegalArgumentException.class,
() -> invokeValidateCommand(getExecutor(), List.of("../../bin/evil")));
}
@Test
void testValidateCommand_blankExecutable() {
assertThrows(
IllegalArgumentException.class,
() -> invokeValidateCommand(getExecutor(), List.of(" ")));
}
@Test
void testValidateCommand_validSimpleCommand() throws Exception {
// Simple command names (no path) should pass validation
invokeValidateCommand(getExecutor(), List.of("echo", "hello"));
}
@Test
void testGetInstance_returnsSameInstance() {
ProcessExecutor e1 = ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF);
ProcessExecutor e2 = ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF);
assertSame(e1, e2);
}
@Test
void testGetInstance_differentProcessTypes() {
ProcessExecutor e1 = ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF);
ProcessExecutor e2 = ProcessExecutor.getInstance(ProcessExecutor.Processes.TESSERACT);
assertNotSame(e1, e2);
}
@Test
void testProcessExecutorResult() {
ProcessExecutor executor = getExecutor();
ProcessExecutor.ProcessExecutorResult result =
executor.new ProcessExecutorResult(0, "success");
assertEquals(0, result.getRc());
assertEquals("success", result.getMessages());
result.setRc(1);
result.setMessages("error");
assertEquals(1, result.getRc());
assertEquals("error", result.getMessages());
}
}
@@ -1,82 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
class PropertyConfigsTest {
private static final String TEST_KEY = "stirling.test.property.key";
private static final String TEST_KEY_2 = "stirling.test.property.key2";
@AfterEach
void tearDown() {
System.clearProperty(TEST_KEY);
System.clearProperty(TEST_KEY_2);
}
@Test
void testGetBooleanValue_singleKey_fromSystemProperty() {
System.setProperty(TEST_KEY, "true");
assertTrue(PropertyConfigs.getBooleanValue(TEST_KEY, false));
}
@Test
void testGetBooleanValue_singleKey_defaultWhenMissing() {
assertFalse(PropertyConfigs.getBooleanValue(TEST_KEY, false));
assertTrue(PropertyConfigs.getBooleanValue(TEST_KEY, true));
}
@Test
void testGetBooleanValue_singleKey_falseValue() {
System.setProperty(TEST_KEY, "false");
assertFalse(PropertyConfigs.getBooleanValue(TEST_KEY, true));
}
@Test
void testGetStringValue_singleKey_fromSystemProperty() {
System.setProperty(TEST_KEY, "hello");
assertEquals("hello", PropertyConfigs.getStringValue(TEST_KEY, "default"));
}
@Test
void testGetStringValue_singleKey_defaultWhenMissing() {
assertEquals("default", PropertyConfigs.getStringValue(TEST_KEY, "default"));
}
@Test
void testGetBooleanValue_listKeys_firstMatch() {
System.setProperty(TEST_KEY_2, "true");
assertTrue(PropertyConfigs.getBooleanValue(List.of(TEST_KEY, TEST_KEY_2), false));
}
@Test
void testGetBooleanValue_listKeys_defaultWhenNoneMatch() {
assertFalse(PropertyConfigs.getBooleanValue(List.of(TEST_KEY, TEST_KEY_2), false));
}
@Test
void testGetStringValue_listKeys_firstMatch() {
System.setProperty(TEST_KEY, "first");
System.setProperty(TEST_KEY_2, "second");
assertEquals(
"first", PropertyConfigs.getStringValue(List.of(TEST_KEY, TEST_KEY_2), "default"));
}
@Test
void testGetStringValue_listKeys_defaultWhenNoneMatch() {
assertEquals(
"default",
PropertyConfigs.getStringValue(List.of(TEST_KEY, TEST_KEY_2), "default"));
}
@Test
void testGetBooleanValue_nonBooleanString() {
System.setProperty(TEST_KEY, "notaboolean");
// Boolean.valueOf returns false for non-boolean strings
assertFalse(PropertyConfigs.getBooleanValue(TEST_KEY, true));
}
}
@@ -1,96 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.oauth2.Provider;
class ProviderUtilsAdditionalTest {
@Test
void testValidateProvider_null() {
assertFalse(ProviderUtils.validateProvider(null));
}
@Test
void testValidateProvider_nullClientId() {
Provider provider = new Provider();
provider.setClientId(null);
provider.setClientSecret("secret");
provider.setScopes("read");
assertFalse(ProviderUtils.validateProvider(provider));
}
@Test
void testValidateProvider_emptyClientId() {
Provider provider = new Provider();
provider.setClientId("");
provider.setClientSecret("secret");
provider.setScopes("read");
assertFalse(ProviderUtils.validateProvider(provider));
}
@Test
void testValidateProvider_blankClientId() {
Provider provider = new Provider();
provider.setClientId(" ");
provider.setClientSecret("secret");
provider.setScopes("read");
assertFalse(ProviderUtils.validateProvider(provider));
}
@Test
void testValidateProvider_nullClientSecret() {
Provider provider = new Provider();
provider.setClientId("id");
provider.setClientSecret(null);
provider.setScopes("read");
assertFalse(ProviderUtils.validateProvider(provider));
}
@Test
void testValidateProvider_emptyClientSecret() {
Provider provider = new Provider();
provider.setClientId("id");
provider.setClientSecret("");
provider.setScopes("read");
assertFalse(ProviderUtils.validateProvider(provider));
}
@Test
void testValidateProvider_nullScopes() {
Provider provider = new Provider();
provider.setClientId("id");
provider.setClientSecret("secret");
provider.setScopes(null);
assertFalse(ProviderUtils.validateProvider(provider));
}
@Test
void testValidateProvider_emptyScopes() {
Provider provider = new Provider();
provider.setClientId("id");
provider.setClientSecret("secret");
provider.setScopes("");
assertFalse(ProviderUtils.validateProvider(provider));
}
@Test
void testValidateProvider_allFieldsValid() {
Provider provider = new Provider();
provider.setClientId("my-client-id");
provider.setClientSecret("my-secret");
provider.setScopes("openid,profile");
assertTrue(ProviderUtils.validateProvider(provider));
}
@Test
void testValidateProvider_singleScope() {
Provider provider = new Provider();
provider.setClientId("id");
provider.setClientSecret("secret");
provider.setScopes("email");
assertTrue(ProviderUtils.validateProvider(provider));
}
}
@@ -1,164 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class RequestUriUtilsTest {
// --- isStaticResource tests ---
@Test
void testIsStaticResource_nullUri() {
assertFalse(RequestUriUtils.isStaticResource(null));
}
@Test
void testIsStaticResource_cssDirectory() {
assertTrue(RequestUriUtils.isStaticResource("/css/style.css"));
}
@Test
void testIsStaticResource_jsDirectory() {
assertTrue(RequestUriUtils.isStaticResource("/js/app.js"));
}
@Test
void testIsStaticResource_imagesDirectory() {
assertTrue(RequestUriUtils.isStaticResource("/images/logo.png"));
}
@Test
void testIsStaticResource_robotsTxt() {
assertTrue(RequestUriUtils.isStaticResource("/robots.txt"));
}
@Test
void testIsStaticResource_faviconIco() {
assertTrue(RequestUriUtils.isStaticResource("/favicon.ico"));
}
@Test
void testIsStaticResource_loginPath() {
assertTrue(RequestUriUtils.isStaticResource("/login"));
}
@Test
void testIsStaticResource_errorPath() {
assertTrue(RequestUriUtils.isStaticResource("/error"));
}
@Test
void testIsStaticResource_svgExtension() {
assertTrue(RequestUriUtils.isStaticResource("/some/path/icon.svg"));
}
@Test
void testIsStaticResource_apiRoute_notStatic() {
assertFalse(RequestUriUtils.isStaticResource("/api/v1/convert"));
}
@Test
void testIsStaticResource_apiStatusEndpoint() {
assertTrue(RequestUriUtils.isStaticResource("/api/v1/info/status"));
}
@Test
void testIsStaticResource_withContextPath() {
assertTrue(RequestUriUtils.isStaticResource("/app", "/app/css/style.css"));
}
@Test
void testIsStaticResource_mobileScannerPath() {
assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner"));
}
// --- isFrontendRoute tests ---
@Test
void testIsFrontendRoute_nullUri() {
assertFalse(RequestUriUtils.isFrontendRoute("", null));
}
@Test
void testIsFrontendRoute_apiPath() {
assertFalse(RequestUriUtils.isFrontendRoute("", "/api/v1/convert"));
}
@Test
void testIsFrontendRoute_backendOnlyPath() {
assertFalse(RequestUriUtils.isFrontendRoute("", "/swagger"));
assertFalse(RequestUriUtils.isFrontendRoute("", "/register"));
assertFalse(RequestUriUtils.isFrontendRoute("", "/actuator"));
}
@Test
void testIsFrontendRoute_extensionlessPath() {
assertTrue(RequestUriUtils.isFrontendRoute("", "/merge"));
assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf"));
}
@Test
void testIsFrontendRoute_pathWithExtension() {
assertFalse(RequestUriUtils.isFrontendRoute("", "/some/file.pdf"));
}
@Test
void testIsFrontendRoute_blankPath() {
assertFalse(RequestUriUtils.isFrontendRoute("", ""));
}
// --- isTrackableResource tests ---
@Test
void testIsTrackableResource_jsPath() {
assertFalse(RequestUriUtils.isTrackableResource("/js/app.js"));
}
@Test
void testIsTrackableResource_cssFile() {
assertFalse(RequestUriUtils.isTrackableResource("/some/file.css"));
}
@Test
void testIsTrackableResource_apiPage() {
assertTrue(RequestUriUtils.isTrackableResource("/api/v1/convert"));
}
@Test
void testIsTrackableResource_swaggerPath() {
assertFalse(RequestUriUtils.isTrackableResource("/swagger-ui/index.html"));
}
@Test
void testIsTrackableResource_infoApi() {
assertFalse(RequestUriUtils.isTrackableResource("/api/v1/info/status"));
}
// --- isPublicAuthEndpoint tests ---
@Test
void testIsPublicAuthEndpoint_loginPath() {
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/login", ""));
}
@Test
void testIsPublicAuthEndpoint_oauthPath() {
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/oauth2/authorization/google", ""));
}
@Test
void testIsPublicAuthEndpoint_healthEndpoint() {
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/actuator/health", ""));
}
@Test
void testIsPublicAuthEndpoint_regularApiNotPublic() {
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/convert", ""));
}
@Test
void testIsPublicAuthEndpoint_withContextPath() {
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/login", "/app"));
}
}
@@ -1,100 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.SsrfProtectionService;
class SvgSanitizerTest {
private SvgSanitizer sanitizer;
private ApplicationProperties applicationProperties;
private SsrfProtectionService ssrfProtectionService;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
ssrfProtectionService = mock(SsrfProtectionService.class);
sanitizer = new SvgSanitizer(ssrfProtectionService, applicationProperties);
}
@Test
void testSanitize_validSvg() throws IOException {
String svg = "<svg xmlns=\"http://www.w3.org/2000/svg\"><circle r=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
assertNotNull(result);
assertTrue(result.length > 0);
String output = new String(result, StandardCharsets.UTF_8);
assertTrue(output.contains("circle"));
}
@Test
void testSanitize_removesScriptElement() throws IOException {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert('xss')</script><circle r=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("script"));
assertTrue(output.contains("circle"));
}
@Test
void testSanitize_removesEventHandler() throws IOException {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\"><circle r=\"10\" onclick=\"alert('xss')\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("onclick"));
}
@Test
void testSanitize_removesJavascriptUrl() throws IOException {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\"><a href=\"javascript:alert('xss')\"><circle r=\"10\"/></a></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("javascript"));
}
@Test
void testSanitize_nullInput() {
assertThrows(IOException.class, () -> sanitizer.sanitize(null));
}
@Test
void testSanitize_emptyInput() {
assertThrows(IOException.class, () -> sanitizer.sanitize(new byte[0]));
}
@Test
void testSanitize_disabledByConfig() throws IOException {
applicationProperties.getSystem().setDisableSanitize(true);
byte[] input =
"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>evil</script></svg>"
.getBytes(StandardCharsets.UTF_8);
byte[] result = sanitizer.sanitize(input);
assertArrayEquals(input, result);
}
@Test
void testSanitize_removesForeignObject() throws IOException {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\"><foreignObject><body>evil</body></foreignObject><rect width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.toLowerCase().contains("foreignobject"));
}
@Test
void testSanitize_invalidXml() {
byte[] invalid = "not xml at all".getBytes(StandardCharsets.UTF_8);
assertThrows(IOException.class, () -> sanitizer.sanitize(invalid));
}
}
@@ -1,137 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.File;
import java.io.IOException;
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 stirling.software.common.model.ApplicationProperties;
class TempFileManagerTest {
private TempFileManager manager;
private TempFileRegistry registry;
private ApplicationProperties applicationProperties;
@TempDir Path tempDir;
@BeforeEach
void setUp() {
registry = new TempFileRegistry();
applicationProperties = new ApplicationProperties();
applicationProperties.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
applicationProperties.getSystem().getTempFileManagement().setPrefix("test-stirling-");
manager = new TempFileManager(registry, applicationProperties);
}
@Test
void testCreateTempFile() throws IOException {
File file = manager.createTempFile(".pdf");
assertNotNull(file);
assertTrue(file.exists());
assertTrue(file.getName().endsWith(".pdf"));
assertTrue(registry.contains(file));
}
@Test
void testCreateManagedTempFile() throws IOException {
TempFile tempFile = manager.createManagedTempFile(".txt");
assertNotNull(tempFile);
assertTrue(tempFile.exists());
assertTrue(tempFile.getFile().getName().endsWith(".txt"));
}
@Test
void testCreateTempDirectory() throws IOException {
Path dir = manager.createTempDirectory();
assertNotNull(dir);
assertTrue(Files.isDirectory(dir));
assertTrue(registry.getTempDirectories().contains(dir));
}
@Test
void testDeleteTempFile_file() throws IOException {
File file = manager.createTempFile(".tmp");
assertTrue(file.exists());
boolean deleted = manager.deleteTempFile(file);
assertTrue(deleted);
assertFalse(file.exists());
assertFalse(registry.contains(file));
}
@Test
void testDeleteTempFile_path() throws IOException {
File file = manager.createTempFile(".tmp");
Path path = file.toPath();
assertTrue(Files.exists(path));
boolean deleted = manager.deleteTempFile(path);
assertTrue(deleted);
assertFalse(Files.exists(path));
}
@Test
void testDeleteTempFile_nullFile() {
assertFalse(manager.deleteTempFile((File) null));
}
@Test
void testDeleteTempFile_nullPath() {
assertFalse(manager.deleteTempFile((Path) null));
}
@Test
void testDeleteTempFile_nonExistentFile() {
File nonExistent = new File(tempDir.toFile(), "does-not-exist.tmp");
assertFalse(manager.deleteTempFile(nonExistent));
}
@Test
void testRegister() throws IOException {
File file = Files.createTempFile(tempDir, "existing", ".tmp").toFile();
File result = manager.register(file);
assertSame(file, result);
assertTrue(registry.contains(file));
}
@Test
void testRegister_nullFile() {
File result = manager.register(null);
assertNull(result);
}
@Test
void testGenerateTempFileName() {
String name = manager.generateTempFileName("convert", "pdf");
assertNotNull(name);
assertTrue(name.startsWith("test-stirling-"));
assertTrue(name.contains("convert"));
assertTrue(name.endsWith(".pdf"));
}
@Test
void testGetMaxAgeMillis() {
applicationProperties.getSystem().getTempFileManagement().setMaxAgeHours(2);
long millis = manager.getMaxAgeMillis();
assertEquals(2 * 60 * 60 * 1000L, millis);
}
@Test
void testCleanupOldTempFiles() throws IOException, InterruptedException {
File file = manager.createTempFile(".tmp");
assertTrue(file.exists());
Thread.sleep(50);
int deleted = manager.cleanupOldTempFiles(10);
assertTrue(deleted >= 1);
assertFalse(file.exists());
}
}
@@ -1,131 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class TempFileRegistryTest {
private TempFileRegistry registry;
@TempDir Path tempDir;
@BeforeEach
void setUp() {
registry = new TempFileRegistry();
}
@Test
void testRegisterFile() throws IOException {
File file = Files.createTempFile(tempDir, "test", ".tmp").toFile();
File result = registry.register(file);
assertSame(file, result);
assertTrue(registry.contains(file));
}
@Test
void testRegisterNull() {
registry.register((File) null);
assertEquals(0, registry.getAllRegisteredFiles().size());
}
@Test
void testRegisterPath() throws IOException {
Path path = Files.createTempFile(tempDir, "test", ".tmp");
Path result = registry.register(path);
assertSame(path, result);
assertTrue(registry.getAllRegisteredFiles().contains(path));
}
@Test
void testUnregisterFile() throws IOException {
File file = Files.createTempFile(tempDir, "test", ".tmp").toFile();
registry.register(file);
assertTrue(registry.contains(file));
registry.unregister(file);
assertFalse(registry.contains(file));
}
@Test
void testUnregisterPath() throws IOException {
Path path = Files.createTempFile(tempDir, "test", ".tmp");
registry.register(path);
registry.unregister(path);
assertFalse(registry.getAllRegisteredFiles().contains(path));
}
@Test
void testUnregisterNull() {
// Should not throw
registry.unregister((File) null);
registry.unregister((Path) null);
}
@Test
void testRegisterDirectory() throws IOException {
Path dir = Files.createTempDirectory(tempDir, "testdir");
Path result = registry.registerDirectory(dir);
assertSame(dir, result);
assertTrue(registry.getTempDirectories().contains(dir));
}
@Test
void testRegisterThirdParty() throws IOException {
File file = Files.createTempFile(tempDir, "third", ".tmp").toFile();
File result = registry.registerThirdParty(file);
assertSame(file, result);
assertTrue(registry.getThirdPartyTempFiles().contains(file.toPath()));
assertTrue(registry.contains(file));
}
@Test
void testContainsNull() {
assertFalse(registry.contains(null));
}
@Test
void testGetFilesOlderThan() throws IOException, InterruptedException {
Path path = Files.createTempFile(tempDir, "old", ".tmp");
registry.register(path);
// Files registered just now should not be "older than 0ms" since
// getFilesOlderThan uses isBefore(cutoff), meaning strictly before
Thread.sleep(50);
Set<Path> oldFiles = registry.getFilesOlderThan(10);
assertTrue(oldFiles.contains(path));
}
@Test
void testGetFilesOlderThan_recentFiles() throws IOException {
Path path = Files.createTempFile(tempDir, "recent", ".tmp");
registry.register(path);
// With a very large maxAge, no files should be "old"
Set<Path> oldFiles = registry.getFilesOlderThan(999_999_999);
assertFalse(oldFiles.contains(path));
}
@Test
void testClear() throws IOException {
File file = Files.createTempFile(tempDir, "clear", ".tmp").toFile();
Path dir = Files.createTempDirectory(tempDir, "cleardir");
registry.register(file);
registry.registerThirdParty(file);
registry.registerDirectory(dir);
registry.clear();
assertEquals(0, registry.getAllRegisteredFiles().size());
assertEquals(0, registry.getThirdPartyTempFiles().size());
assertEquals(0, registry.getTempDirectories().size());
}
}
@@ -1,80 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
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 stirling.software.common.model.ApplicationProperties;
class TempFileTest {
private TempFileManager manager;
@TempDir Path tempDir;
@BeforeEach
void setUp() {
TempFileRegistry registry = new TempFileRegistry();
ApplicationProperties props = new ApplicationProperties();
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
props.getSystem().getTempFileManagement().setPrefix("test-");
manager = new TempFileManager(registry, props);
}
@Test
void testTempFileCreation() throws IOException {
TempFile tempFile = new TempFile(manager, ".pdf");
assertNotNull(tempFile.getFile());
assertTrue(tempFile.exists());
assertTrue(tempFile.getFile().getName().endsWith(".pdf"));
}
@Test
void testGetPath() throws IOException {
TempFile tempFile = new TempFile(manager, ".txt");
Path path = tempFile.getPath();
assertNotNull(path);
assertEquals(tempFile.getFile().toPath(), path);
}
@Test
void testGetAbsolutePath() throws IOException {
TempFile tempFile = new TempFile(manager, ".tmp");
String absPath = tempFile.getAbsolutePath();
assertNotNull(absPath);
assertEquals(tempFile.getFile().getAbsolutePath(), absPath);
}
@Test
void testClose_deletesFile() throws IOException {
TempFile tempFile = new TempFile(manager, ".tmp");
assertTrue(tempFile.exists());
tempFile.close();
assertFalse(tempFile.exists());
}
@Test
void testTryWithResources() throws IOException {
TempFile tempFileRef;
try (TempFile tempFile = new TempFile(manager, ".tmp")) {
tempFileRef = tempFile;
assertTrue(tempFile.exists());
}
assertFalse(tempFileRef.exists());
}
@Test
void testToString() throws IOException {
TempFile tempFile = new TempFile(manager, ".tmp");
String str = tempFile.toString();
assertTrue(str.startsWith("TempFile{"));
assertTrue(str.endsWith("}"));
assertTrue(str.contains(tempFile.getFile().getAbsolutePath()));
}
}
@@ -1,131 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import stirling.software.common.model.ApplicationProperties;
class TempFileUtilTest {
private TempFileManager manager;
@TempDir Path tempDir;
@BeforeEach
void setUp() {
TempFileRegistry registry = new TempFileRegistry();
ApplicationProperties props = new ApplicationProperties();
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
props.getSystem().getTempFileManagement().setPrefix("test-");
manager = new TempFileManager(registry, props);
}
@Test
void testWithTempFile_executesAndCleansUp() throws IOException {
final File[] fileRef = new File[1];
String result =
TempFileUtil.withTempFile(
manager,
".tmp",
file -> {
fileRef[0] = file;
assertTrue(file.exists());
return "done";
});
assertEquals("done", result);
assertFalse(fileRef[0].exists());
}
@Test
void testWithMultipleTempFiles() throws IOException {
final List<File>[] filesRef = new List[1];
String result =
TempFileUtil.withMultipleTempFiles(
manager,
3,
".tmp",
files -> {
filesRef[0] = files;
assertEquals(3, files.size());
for (File f : files) {
assertTrue(f.exists());
}
return "ok";
});
assertEquals("ok", result);
for (File f : filesRef[0]) {
assertFalse(f.exists());
}
}
@Test
void testSafeDeleteFiles() throws IOException {
Path file1 = Files.createTempFile(tempDir, "safe", ".tmp");
Path file2 = Files.createTempFile(tempDir, "safe", ".tmp");
assertTrue(Files.exists(file1));
assertTrue(Files.exists(file2));
TempFileUtil.safeDeleteFiles(Arrays.asList(file1, file2));
assertFalse(Files.exists(file1));
assertFalse(Files.exists(file2));
}
@Test
void testSafeDeleteFiles_nullList() {
// Should not throw
TempFileUtil.safeDeleteFiles(null);
}
@Test
void testSafeDeleteFiles_nullElement() throws IOException {
Path file = Files.createTempFile(tempDir, "safe", ".tmp");
// Should handle null elements gracefully
TempFileUtil.safeDeleteFiles(Arrays.asList(null, file));
assertFalse(Files.exists(file));
}
@Test
void testRegisterExistingTempFile() throws IOException {
File file = Files.createTempFile(tempDir, "existing", ".tmp").toFile();
File result = TempFileUtil.registerExistingTempFile(manager, file);
assertSame(file, result);
}
@Test
void testRegisterExistingTempFile_nullManager() throws IOException {
File file = Files.createTempFile(tempDir, "existing", ".tmp").toFile();
File result = TempFileUtil.registerExistingTempFile(null, file);
assertSame(file, result);
}
@Test
void testRegisterExistingTempFile_nullFile() {
File result = TempFileUtil.registerExistingTempFile(manager, null);
assertNull(result);
}
@Test
void testTempFileCollection() throws IOException {
TempFileUtil.TempFileCollection collection = new TempFileUtil.TempFileCollection(manager);
File f1 = collection.addTempFile(".tmp");
File f2 = collection.addTempFile(".pdf");
assertTrue(f1.exists());
assertTrue(f2.exists());
assertEquals(2, collection.getFiles().size());
collection.close();
assertFalse(f1.exists());
assertFalse(f2.exists());
}
}
@@ -1,122 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
import jakarta.servlet.http.HttpServletRequest;
class UrlUtilsTest {
@Test
void testGetOrigin_standardRequest() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
assertEquals("http://localhost:8080", UrlUtils.getOrigin(request));
}
@Test
void testGetOrigin_httpsWithContextPath() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getScheme()).thenReturn("https");
when(request.getServerName()).thenReturn("example.com");
when(request.getServerPort()).thenReturn(443);
when(request.getContextPath()).thenReturn("/myapp");
assertEquals("https://example.com:443/myapp", UrlUtils.getOrigin(request));
}
@Test
void testIsPortAvailable_usedPort() {
// Port 0 is special - let the OS pick a port, but commonly used ports should be busy
// We test with a high port that might be available
// This is inherently environment-dependent
boolean result = UrlUtils.isPortAvailable(0);
// Port 0 should always be available as the OS assigns an ephemeral port
assertTrue(result);
}
@Test
void testFindAvailablePort_returnsPort() {
// Starting from port 0 should immediately find an available port
String port = UrlUtils.findAvailablePort(0);
assertNotNull(port);
int portNum = Integer.parseInt(port);
assertTrue(portNum >= 0);
}
@Test
void testGetOrigin_customPort() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("192.168.1.1");
when(request.getServerPort()).thenReturn(9090);
when(request.getContextPath()).thenReturn("/api");
assertEquals("http://192.168.1.1:9090/api", UrlUtils.getOrigin(request));
}
@Test
void testGetOrigin_defaultPort80() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("example.com");
when(request.getServerPort()).thenReturn(80);
when(request.getContextPath()).thenReturn("");
assertEquals("http://example.com:80", UrlUtils.getOrigin(request));
}
@Test
void testGetOrigin_emptyContextPath() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getScheme()).thenReturn("https");
when(request.getServerName()).thenReturn("app.example.com");
when(request.getServerPort()).thenReturn(443);
when(request.getContextPath()).thenReturn("");
assertEquals("https://app.example.com:443", UrlUtils.getOrigin(request));
}
@Test
void testGetOrigin_nestedContextPath() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("host");
when(request.getServerPort()).thenReturn(3000);
when(request.getContextPath()).thenReturn("/a/b/c");
assertEquals("http://host:3000/a/b/c", UrlUtils.getOrigin(request));
}
@Test
void testFindAvailablePort_returnsStringOfPort() {
String port = UrlUtils.findAvailablePort(49152);
assertNotNull(port);
int portNum = Integer.parseInt(port);
assertTrue(portNum >= 49152);
}
@Test
void testIsPortAvailable_highPort() {
// Most high ephemeral ports should be available in test environments
// Using port 0 which OS always considers available
assertTrue(UrlUtils.isPortAvailable(0));
}
@Test
void testGetOrigin_ipv4Address() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("10.0.0.1");
when(request.getServerPort()).thenReturn(8443);
when(request.getContextPath()).thenReturn("");
assertEquals("http://10.0.0.1:8443", UrlUtils.getOrigin(request));
}
}
@@ -1,51 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.Test;
class ValidationUtilsTest {
@Test
void testIsStringEmpty_null() {
assertTrue(ValidationUtils.isStringEmpty(null));
}
@Test
void testIsStringEmpty_emptyString() {
assertTrue(ValidationUtils.isStringEmpty(""));
}
@Test
void testIsStringEmpty_blankString() {
assertTrue(ValidationUtils.isStringEmpty(" "));
assertTrue(ValidationUtils.isStringEmpty("\t\n"));
}
@Test
void testIsStringEmpty_nonEmptyString() {
assertFalse(ValidationUtils.isStringEmpty("hello"));
assertFalse(ValidationUtils.isStringEmpty(" a "));
}
@Test
void testIsCollectionEmpty_null() {
assertTrue(ValidationUtils.isCollectionEmpty(null));
}
@Test
void testIsCollectionEmpty_emptyCollection() {
assertTrue(ValidationUtils.isCollectionEmpty(Collections.emptyList()));
assertTrue(ValidationUtils.isCollectionEmpty(new ArrayList<>()));
}
@Test
void testIsCollectionEmpty_nonEmptyCollection() {
assertFalse(ValidationUtils.isCollectionEmpty(List.of("a")));
assertFalse(ValidationUtils.isCollectionEmpty(List.of("a", "b", "c")));
}
}
@@ -1,94 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
class WebResponseUtilsTest {
@Test
void testBytesToWebResponse_defaultMediaType() throws IOException {
byte[] data = "test content".getBytes(StandardCharsets.UTF_8);
ResponseEntity<byte[]> response = WebResponseUtils.bytesToWebResponse(data, "output.pdf");
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(MediaType.APPLICATION_PDF, response.getHeaders().getContentType());
assertEquals(data.length, response.getHeaders().getContentLength());
assertArrayEquals(data, response.getBody());
}
@Test
void testBytesToWebResponse_customMediaType() throws IOException {
byte[] data = "zip data".getBytes(StandardCharsets.UTF_8);
ResponseEntity<byte[]> response =
WebResponseUtils.bytesToWebResponse(
data, "output.zip", MediaType.APPLICATION_OCTET_STREAM);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(MediaType.APPLICATION_OCTET_STREAM, response.getHeaders().getContentType());
assertArrayEquals(data, response.getBody());
}
@Test
void testBaosToWebResponse() throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
baos.write("baos content".getBytes(StandardCharsets.UTF_8));
ResponseEntity<byte[]> response = WebResponseUtils.baosToWebResponse(baos, "doc.pdf");
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertEquals("baos content", new String(response.getBody(), StandardCharsets.UTF_8));
}
@Test
void testBaosToWebResponse_withMediaType() throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
baos.write("data".getBytes(StandardCharsets.UTF_8));
ResponseEntity<byte[]> response =
WebResponseUtils.baosToWebResponse(baos, "doc.html", MediaType.TEXT_HTML);
assertEquals(MediaType.TEXT_HTML, response.getHeaders().getContentType());
}
@Test
void testBytesToWebResponse_contentDispositionHeader() throws IOException {
byte[] data = "test".getBytes(StandardCharsets.UTF_8);
ResponseEntity<byte[]> response = WebResponseUtils.bytesToWebResponse(data, "my file.pdf");
String contentDisposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
assertNotNull(contentDisposition);
assertTrue(contentDisposition.contains("attachment"));
}
@Test
void testBytesToWebResponse_specialCharsInFilename() throws IOException {
byte[] data = "test".getBytes(StandardCharsets.UTF_8);
// A space in the filename gets URL-encoded to '+' then replaced with '%20'
ResponseEntity<byte[]> response =
WebResponseUtils.bytesToWebResponse(data, "file name.pdf");
String contentDisposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
assertNotNull(contentDisposition);
// The space in filename should be encoded as %20 (not +)
assertTrue(contentDisposition.contains("%20"));
}
@Test
void testBytesToWebResponse_emptyBytes() throws IOException {
byte[] data = new byte[0];
ResponseEntity<byte[]> response = WebResponseUtils.bytesToWebResponse(data, "empty.pdf");
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(0, response.getHeaders().getContentLength());
}
}
@@ -1,161 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.snakeyaml.engine.v2.api.LoadSettings;
class YamlHelperTest {
private static final String SIMPLE_YAML =
"server:\n port: 8080\n host: localhost\napp:\n name: test\n debug: true\n";
private static final LoadSettings LOAD_SETTINGS =
LoadSettings.builder()
.setUseMarks(true)
.setMaxAliasesForCollections(Integer.MAX_VALUE)
.setAllowRecursiveKeys(true)
.setParseComments(true)
.build();
private YamlHelper createHelper(String yaml) {
return new YamlHelper(LOAD_SETTINGS, yaml);
}
@Test
void testGetValueByExactKeyPath_scalarValue() {
YamlHelper helper = createHelper(SIMPLE_YAML);
Object value = helper.getValueByExactKeyPath("server", "port");
assertEquals("8080", value);
}
@Test
void testGetValueByExactKeyPath_stringValue() {
YamlHelper helper = new YamlHelper(LOAD_SETTINGS, SIMPLE_YAML);
Object value = helper.getValueByExactKeyPath("server", "host");
assertEquals("localhost", value);
}
@Test
void testGetValueByExactKeyPath_nonExistentKey() {
YamlHelper helper = new YamlHelper(LOAD_SETTINGS, SIMPLE_YAML);
Object value = helper.getValueByExactKeyPath("nonexistent", "key");
assertNull(value);
}
@Test
void testGetAllKeys() {
YamlHelper helper = new YamlHelper(LOAD_SETTINGS, SIMPLE_YAML);
Set<String> keys = helper.getAllKeys();
assertTrue(keys.contains("server"));
assertTrue(keys.contains("server.port"));
assertTrue(keys.contains("server.host"));
assertTrue(keys.contains("app"));
assertTrue(keys.contains("app.name"));
assertTrue(keys.contains("app.debug"));
}
@Test
void testUpdateValue() {
YamlHelper helper = new YamlHelper(LOAD_SETTINGS, SIMPLE_YAML);
boolean updated = helper.updateValue(Arrays.asList("server", "port"), "9090");
assertTrue(updated);
Object newValue = helper.getValueByExactKeyPath("server", "port");
assertEquals("9090", newValue);
}
@Test
void testUpdateValue_nonExistentKey() {
YamlHelper helper = new YamlHelper(LOAD_SETTINGS, SIMPLE_YAML);
boolean updated = helper.updateValue(Arrays.asList("nonexistent", "key"), "value");
assertFalse(updated);
}
@Test
void testConvertNodeToYaml() {
YamlHelper helper = new YamlHelper(LOAD_SETTINGS, SIMPLE_YAML);
String yaml = helper.convertNodeToYaml(helper.getUpdatedRootNode());
assertNotNull(yaml);
assertTrue(yaml.contains("server"));
assertTrue(yaml.contains("port"));
}
@Test
void testConstructorFromFile(@TempDir Path tempDir) throws IOException {
Path yamlFile = tempDir.resolve("test.yaml");
Files.writeString(yamlFile, SIMPLE_YAML);
YamlHelper helper = new YamlHelper(yamlFile);
Object value = helper.getValueByExactKeyPath("app", "name");
assertEquals("test", value);
}
@Test
void testSequenceValues() {
String yaml = "items:\n - alpha\n - beta\n - gamma\n";
YamlHelper helper = new YamlHelper(LOAD_SETTINGS, yaml);
Object value = helper.getValueByExactKeyPath("items");
assertInstanceOf(List.class, value);
List<?> list = (List<?>) value;
assertEquals(3, list.size());
assertEquals("alpha", list.get(0));
}
// --- Static type check methods ---
@Test
void testIsInteger() {
assertTrue(YamlHelper.isInteger(42));
assertTrue(YamlHelper.isInteger("123"));
assertFalse(YamlHelper.isInteger("abc"));
assertFalse(YamlHelper.isInteger(3.14));
}
@Test
void testIsFloat() {
assertTrue(YamlHelper.isFloat(3.14f));
assertTrue(YamlHelper.isFloat(3.14));
assertTrue(YamlHelper.isFloat("3.14"));
assertFalse(YamlHelper.isFloat("abc"));
}
@Test
void testIsLong() {
assertTrue(YamlHelper.isLong(42L));
assertTrue(YamlHelper.isLong("9999999999"));
assertFalse(YamlHelper.isLong("notALong"));
}
@Test
void testIsAnyInteger() {
assertTrue(YamlHelper.isAnyInteger(42));
assertTrue(YamlHelper.isAnyInteger((short) 5));
assertTrue(YamlHelper.isAnyInteger((byte) 1));
assertTrue(YamlHelper.isAnyInteger(100L));
assertFalse(YamlHelper.isAnyInteger("xyz"));
}
@Test
void testSave_differentPath(@TempDir Path tempDir) throws IOException {
Path originalFile = tempDir.resolve("original.yaml");
Files.writeString(originalFile, SIMPLE_YAML);
YamlHelper helper = new YamlHelper(originalFile);
helper.updateValue(Arrays.asList("server", "port"), "9090");
Path savePath = tempDir.resolve("saved.yaml");
helper.save(savePath);
assertTrue(Files.exists(savePath));
String content = Files.readString(savePath);
assertTrue(content.contains("9090"));
}
}
@@ -48,144 +48,25 @@ public class MultiPageLayoutController {
public ResponseEntity<byte[]> mergeMultiplePagesIntoOne(
@ModelAttribute MergeMultiplePagesRequest request) throws IOException {
int MAX_PAGES = 100000;
int MAX_COLS = 300;
int MAX_ROWS = 300;
String mode = request.getMode();
if (mode == null || mode.trim().isEmpty()) {
mode = "DEFAULT";
}
int rows;
int cols;
int pagesPerSheet;
switch (mode) {
case "DEFAULT":
pagesPerSheet = request.getPagesPerSheet();
if (pagesPerSheet != 2
&& pagesPerSheet
!= (int) Math.sqrt(pagesPerSheet) * Math.sqrt(pagesPerSheet)) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"pagesPerSheet",
"must be 2 or a perfect square");
}
cols = pagesPerSheet == 2 ? pagesPerSheet : (int) Math.sqrt(pagesPerSheet);
rows = pagesPerSheet == 2 ? 1 : (int) Math.sqrt(pagesPerSheet);
break;
case "CUSTOM":
rows = request.getRows();
cols = request.getCols();
if (rows <= 0 || cols <= 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"rows and cols",
"only strictly positive values are allowed");
}
pagesPerSheet = cols * rows;
break;
default:
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"mode",
"only 'DEFAULT' and 'CUSTOM' are supported");
}
if (pagesPerSheet > MAX_PAGES) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid {0} format: {1}",
"pagesPerSheet",
"must be less than " + MAX_PAGES);
}
if (cols > MAX_COLS) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid {0} format: {1}",
"cols",
"must be less than " + MAX_COLS);
}
if (rows > MAX_ROWS) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid {0} format: {1}",
"rows",
"must be less than " + MAX_ROWS);
}
String orientation = request.getOrientation();
if (orientation == null || orientation.trim().isEmpty()) {
orientation = "PORTRAIT";
}
if (!"PORTRAIT".equals(orientation) && !"LANDSCAPE".equals(orientation)) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"orientation",
"only 'PORTRAIT' and 'LANDSCAPE' are supported");
}
String arrangement = request.getArrangement();
if (arrangement == null || arrangement.trim().isEmpty()) {
arrangement = "BY_ROWS";
}
if (!"BY_ROWS".equals(arrangement) && !"BY_COLUMNS".equals(arrangement)) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"arrangement",
"only 'BY_ROWS' and 'BY_COLUMNS' are supported");
}
String readingDirection = request.getReadingDirection();
if (readingDirection == null || readingDirection.trim().isEmpty()) {
readingDirection = "LTR";
}
if (!"LTR".equals(readingDirection) && !"RTL".equals(readingDirection)) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"readingDirection",
"only 'LTR' and 'RTL' are supported");
}
int pagesPerSheet = request.getPagesPerSheet();
MultipartFile file = request.getFileInput();
boolean addBorder = Boolean.TRUE.equals(request.getAddBorder());
int topMargin = request.getTopMargin();
int bottomMargin = request.getBottomMargin();
int leftMargin = request.getLeftMargin();
int rightMargin = request.getRightMargin();
int innerMargin = request.getInnerMargin();
if (topMargin < 0
|| bottomMargin < 0
|| leftMargin < 0
|| rightMargin < 0
|| innerMargin < 0) {
if (pagesPerSheet != 2
&& pagesPerSheet != 3
&& pagesPerSheet != (int) Math.sqrt(pagesPerSheet) * Math.sqrt(pagesPerSheet)) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"Margins",
"only positive values are allowed");
"pagesPerSheet",
"must be 2, 3 or a perfect square");
}
int borderWidth = request.getBorderWidth() == 0 ? 1 : request.getBorderWidth();
if (addBorder && borderWidth <= 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"borderWidth",
"only strictly positive values are allowed when addBorder is true");
}
MultipartFile file = request.getFileInput();
int cols =
pagesPerSheet == 2 || pagesPerSheet == 3
? pagesPerSheet
: (int) Math.sqrt(pagesPerSheet);
int rows = pagesPerSheet == 2 || pagesPerSheet == 3 ? 1 : (int) Math.sqrt(pagesPerSheet);
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
try (PDDocument newDocument =
@@ -193,53 +74,16 @@ public class MultiPageLayoutController {
int totalPages = sourceDocument.getNumberOfPages();
LayerUtility layerUtility = new LayerUtility(newDocument);
// Margin between page and content:
float pageWidth =
"PORTRAIT".equals(orientation)
? PDRectangle.A4.getWidth()
: PDRectangle.A4.getHeight();
float pageHeight =
"PORTRAIT".equals(orientation)
? PDRectangle.A4.getHeight()
: PDRectangle.A4.getWidth();
// Calculate cell dimensions once (all output pages are A4) - declare outside try
// blocks
float cellWidth = (pageWidth - leftMargin - rightMargin) / cols;
float cellHeight = (pageHeight - topMargin - bottomMargin) / rows;
// Validate that outer margins and grid configuration yield positive cell size
if (cellWidth <= 0 || cellHeight <= 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"margin/layout configuration",
"Invalid margin or layout configuration: resulting cell size is non-positive. "
+ "Please reduce outer margins or adjust rows/columns.");
}
float innerWidth = cellWidth - 2 * innerMargin;
float innerHeight = cellHeight - 2 * innerMargin;
// Validate that inner margin fits within each cell
if (innerWidth <= 0 || innerHeight <= 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"inner margin",
"Invalid inner margin: resulting inner content area is non-positive. "
+ "Please reduce inner margin or adjust outer margins/layout.");
}
float cellWidth = PDRectangle.A4.getWidth() / cols;
float cellHeight = PDRectangle.A4.getHeight() / rows;
// Process pages in groups of pagesPerSheet, creating a new page and content stream
// for each group
for (int i = 0; i < totalPages; i += pagesPerSheet) {
// Create a new output page for each group of pagesPerSheet
// Create a new A4 landscape rectangle that we use when orientation is landscape
PDRectangle a4Landscape =
new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth());
PDPage newPage =
"PORTRAIT".equals(orientation)
? new PDPage(PDRectangle.A4)
: new PDPage(a4Landscape);
PDPage newPage = new PDPage(PDRectangle.A4);
newDocument.addPage(newPage);
// Use try-with-resources for each content stream to ensure proper cleanup
@@ -251,52 +95,30 @@ public class MultiPageLayoutController {
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
if (addBorder) {
contentStream.setLineWidth(borderWidth);
contentStream.setStrokingColor(Color.BLACK);
}
float borderThickness = 1.5f; // Specify border thickness as required
contentStream.setLineWidth(borderThickness);
contentStream.setStrokingColor(Color.BLACK);
// Process all pages in this group
for (int j = 0; j < pagesPerSheet && (i + j) < totalPages; j++) {
int pageIndex = i + j;
PDPage sourcePage = sourceDocument.getPage(pageIndex);
PDRectangle rect = sourcePage.getMediaBox();
float scaleWidth = innerWidth / rect.getWidth();
float scaleHeight = innerHeight / rect.getHeight();
float scaleWidth = cellWidth / rect.getWidth();
float scaleHeight = cellHeight / rect.getHeight();
float scale = Math.min(scaleWidth, scaleHeight);
int adjustedPageIndex = j % pagesPerSheet;
int rowIndex;
int colIndex;
if ("BY_ROWS".equals(arrangement)) {
rowIndex = adjustedPageIndex / cols;
if ("LTR".equals(readingDirection)) {
colIndex = adjustedPageIndex % cols;
} else {
colIndex = cols - 1 - (adjustedPageIndex % cols);
}
} else {
rowIndex = adjustedPageIndex % rows;
if ("LTR".equals(readingDirection)) {
colIndex = adjustedPageIndex / rows;
} else {
colIndex = cols - 1 - (adjustedPageIndex / rows);
}
}
int rowIndex = adjustedPageIndex / cols;
int colIndex = adjustedPageIndex % cols;
float x =
leftMargin
+ colIndex * cellWidth
+ innerMargin
+ (innerWidth - rect.getWidth() * scale) / 2;
colIndex * cellWidth
+ (cellWidth - rect.getWidth() * scale) / 2;
float y =
newPage.getMediaBox().getHeight()
- topMargin
- ((rowIndex + 1) * cellHeight
- innerMargin
- (innerHeight - rect.getHeight() * scale) / 2);
- (cellHeight - rect.getHeight() * scale) / 2);
contentStream.saveGraphicsState();
contentStream.transform(Matrix.getTranslateInstance(x, y));
@@ -310,8 +132,11 @@ public class MultiPageLayoutController {
if (addBorder) {
// Draw border around each page
contentStream.addRect(
x, y, rect.getWidth() * scale, rect.getHeight() * scale);
float borderX = colIndex * cellWidth;
float borderY =
newPage.getMediaBox().getHeight()
- (rowIndex + 1) * cellHeight;
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
contentStream.stroke();
}
}
@@ -320,7 +145,7 @@ public class MultiPageLayoutController {
// If any source page is rotated, skip form copying/transformation entirely
boolean hasRotation = GeneralFormCopyUtils.hasAnyRotatedPage(sourceDocument);
if (hasRotation || "LANDSCAPE".equals(orientation)) {
if (hasRotation) {
log.info("Source document has rotated pages; skipping form field copying.");
} else {
try {
@@ -17,7 +17,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
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.model.ApplicationProperties;
@@ -204,27 +203,6 @@ public class ConfigController {
boolean invitesEnabled = applicationProperties.getMail().isEnableInvites();
configData.put("enableEmailInvites", smtpEnabled && invitesEnabled);
// Storage settings
boolean storageEnabled = enableLogin && applicationProperties.getStorage().isEnabled();
boolean sharingEnabled =
storageEnabled && applicationProperties.getStorage().getSharing().isEnabled();
boolean frontendUrlConfigured = frontendUrl != null && !frontendUrl.trim().isEmpty();
boolean shareLinksEnabled =
sharingEnabled
&& applicationProperties.getStorage().getSharing().isLinkEnabled()
&& frontendUrlConfigured;
boolean shareEmailEnabled =
sharingEnabled
&& applicationProperties.getStorage().getSharing().isEmailEnabled()
&& applicationProperties.getMail().isEnabled();
boolean groupSigningEnabled =
storageEnabled && applicationProperties.getStorage().getSigning().isEnabled();
configData.put("storageEnabled", storageEnabled);
configData.put("storageSharingEnabled", sharingEnabled);
configData.put("storageShareLinksEnabled", shareLinksEnabled);
configData.put("storageShareEmailEnabled", shareEmailEnabled);
configData.put("storageGroupSigningEnabled", groupSigningEnabled);
// Check if user is admin using UserServiceInterface
boolean isAdmin = false;
if (userService != null) {
@@ -252,6 +230,16 @@ public class ConfigController {
}
configData.put("isNewUser", isNewUser);
String username = null;
if (userService != null) {
try {
username = userService.getCurrentUsername();
} catch (Exception e) {
// Not authenticated or security disabled
}
}
configData.put("username", username);
// System settings
configData.put(
"enableAlphaFunctionality",
@@ -267,13 +255,6 @@ public class ConfigController {
// Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
// Timestamp TSA settings — single source of truth for presets + admin URLs
ApplicationProperties.Security.Timestamp tsConfig =
applicationProperties.getSecurity().getTimestamp();
configData.put("timestampDefaultTsaUrl", tsConfig.getDefaultTsaUrl());
configData.put("timestampCustomTsaUrls", tsConfig.getCustomTsaUrls());
configData.put("timestampTsaPresets", TimestampController.TSA_PRESETS);
// Server certificate settings
configData.put(
"serverCertificateEnabled",
@@ -66,8 +66,7 @@ public class ExtractImagesController {
Set<Integer> processedImageHashes = new HashSet<>();
TempFile zipFile = new TempFile(tempFileManager, ".zip");
try (ZipOutputStream zipStream =
new ZipOutputStream(Files.newOutputStream(zipFile.getPath()));
try (ZipOutputStream zipStream = new ZipOutputStream(Files.newOutputStream(zipFile.getPath()));
PDDocument pdfDoc = pdfDocumentFactory.load(file)) {
zipStream.setLevel(Deflater.BEST_COMPRESSION);
@@ -76,12 +75,8 @@ public class ExtractImagesController {
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage currentPage = pdfDoc.getPage(pageIndex);
extractAndAddImagesToZip(
currentPage,
imageFormat,
baseFilename,
pageIndex + 1,
processedImageHashes,
zipStream);
currentPage, imageFormat, baseFilename, pageIndex + 1,
processedImageHashes, zipStream);
}
} catch (Exception e) {
zipFile.close();
@@ -124,12 +119,7 @@ public class ExtractImagesController {
BufferedImage convertedImage = convertImageToFormat(sourceImage, imageFormat);
String imagePath =
baseFilename
+ "_page_"
+ pageNumber
+ "_"
+ imageCount++
+ "."
baseFilename + "_page_" + pageNumber + "_" + imageCount++ + "."
+ imageFormat;
ByteArrayOutputStream imageBuffer = new ByteArrayOutputStream();
ImageIO.write(convertedImage, imageFormat, imageBuffer);
@@ -1,136 +0,0 @@
package stirling.software.SPDF.controller.api.misc;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@GeneralApi
@Slf4j
@RequiredArgsConstructor
public class RemoveImagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/remove-image-pdf")
@Operation(
summary = "Remove images from PDF",
description =
"This endpoint removes all embedded images from a PDF file and returns the"
+ " modified document. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> removeImages(@ModelAttribute PDFFile request) throws IOException {
MultipartFile inputFile = request.getFileInput();
try (PDDocument pdfDoc = pdfDocumentFactory.load(request)) {
int totalPages = pdfDoc.getNumberOfPages();
int imagesRemoved = 0;
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage currentPage = pdfDoc.getPage(pageIndex);
imagesRemoved += removeImagesFromPage(currentPage);
}
log.info("Removed {} images from PDF with {} pages", imagesRemoved, totalPages);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
pdfDoc.save(baos);
byte[] pdfContent = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
pdfContent,
GeneralUtils.generateFilename(
inputFile.getOriginalFilename(), "_images_removed.pdf"));
} catch (IOException e) {
throw ExceptionUtils.handlePdfException(e, "during image removal");
}
}
private int removeImagesFromPage(PDPage page) throws IOException {
int imagesRemoved = 0;
PDResources resources = page.getResources();
if (resources == null) {
return imagesRemoved;
}
imagesRemoved += removeImagesFromResources(resources);
return imagesRemoved;
}
private int removeImagesFromFormXObject(PDFormXObject formXObject) throws IOException {
PDResources resources = formXObject.getResources();
if (resources == null) {
return 0;
}
return removeImagesFromResources(resources);
}
private int removeImagesFromResources(PDResources resources) throws IOException {
if (resources == null) {
return 0;
}
COSDictionary xObjects = resources.getCOSObject().getCOSDictionary(COSName.XOBJECT);
if (xObjects == null) {
return 0;
}
int imagesRemoved = 0;
// Create snapshot to safely iterate while removing
List<COSName> names = new ArrayList<>(xObjects.keySet());
for (COSName name : names) {
try {
PDXObject xObject = resources.getXObject(name);
if (xObject == null) {
continue;
}
// Remove direct images
if (xObject instanceof PDImageXObject) {
xObjects.removeItem(name);
imagesRemoved++;
log.debug("Removed image: {}", name.getName());
}
// Recursively process nested form XObjects
else if (xObject instanceof PDFormXObject form) {
imagesRemoved += removeImagesFromResources(form.getResources());
}
} catch (IOException e) {
log.warn("Error processing XObject {}: {}", name.getName(), e.getMessage());
}
}
return imagesRemoved;
}
}
@@ -36,7 +36,6 @@ import stirling.software.SPDF.model.PipelineResult;
import stirling.software.SPDF.service.ApiDocService;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.PostHogService;
import stirling.software.common.util.FileReadinessChecker;
import tools.jackson.databind.ObjectMapper;
@@ -51,7 +50,6 @@ public class PipelineDirectoryProcessor {
private final ApiDocService apiDocService;
private final PipelineProcessor processor;
private final PostHogService postHogService;
private final FileReadinessChecker fileReadinessChecker;
private final List<String> watchedFoldersDirs;
private final String finishedFoldersDir;
@@ -64,13 +62,11 @@ public class PipelineDirectoryProcessor {
ApiDocService apiDocService,
PipelineProcessor processor,
PostHogService postHogService,
FileReadinessChecker fileReadinessChecker,
RuntimePathConfig runtimePathConfig) {
this.objectMapper = objectMapper;
this.apiDocService = apiDocService;
this.processor = processor;
this.postHogService = postHogService;
this.fileReadinessChecker = fileReadinessChecker;
this.watchedFoldersDirs = runtimePathConfig.getPipelineWatchedFoldersPaths();
this.finishedFoldersDir = runtimePathConfig.getPipelineFinishedFoldersPath();
}
@@ -273,18 +269,6 @@ public class PipelineDirectoryProcessor {
}
return isAllowed;
})
.filter(
path -> {
if (!fileReadinessChecker.isReady(path)) {
log.info(
"File '{}' is not yet ready for processing"
+ " (still being written or locked),"
+ " will retry on next scan cycle",
path.getFileName());
return false;
}
return true;
})
.map(Path::toAbsolutePath)
.filter(path -> true)
.map(Path::toFile)
@@ -113,7 +113,7 @@ public class CertSignController {
this.serverCertificateService = serverCertificateService;
}
public static void sign(
private static void sign(
CustomPDFDocumentFactory pdfDocumentFactory,
MultipartFile input,
OutputStream output,
@@ -304,7 +304,7 @@ public class CertSignController {
}
}
public static class CreateSignature extends CreateSignatureBase {
class CreateSignature extends CreateSignatureBase {
File logoFile;
public CreateSignature(KeyStore keystore, char[] pin)
@@ -1,264 +0,0 @@
package stirling.software.SPDF.controller.api.security;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.security.Security;
import java.util.Calendar;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.tsp.TimeStampRequest;
import org.bouncycastle.tsp.TimeStampRequestGenerator;
import org.bouncycastle.tsp.TimeStampResponse;
import org.bouncycastle.tsp.TimeStampToken;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.security.TimestampPdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@Slf4j
@SecurityApi
@RequiredArgsConstructor
public class TimestampController {
static {
Security.addProvider(new BouncyCastleProvider());
}
/** Built-in TSA presets with labels — single source of truth for backend + frontend. */
public static final List<Map<String, String>> TSA_PRESETS =
List.of(
Map.of("label", "DigiCert", "url", "http://timestamp.digicert.com"),
Map.of("label", "Sectigo", "url", "http://timestamp.sectigo.com"),
Map.of("label", "SSL.com", "url", "http://ts.ssl.com"),
Map.of("label", "FreeTSA", "url", "https://freetsa.org/tsr"),
Map.of("label", "MeSign", "url", "http://tsa.mesign.com"));
private static final Set<String> ALLOWED_TSA_PRESET_URLS =
TSA_PRESETS.stream().map(p -> p.get("url")).collect(Collectors.toUnmodifiableSet());
private static final int MAX_TSA_RESPONSE_SIZE = 1024 * 1024; // 1 MB
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ApplicationProperties applicationProperties;
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/timestamp-pdf")
@StandardPdfResponse
@Operation(
summary = "Add RFC 3161 document timestamp to a PDF",
description =
"Contacts a trusted Time Stamp Authority (TSA) server and embeds an RFC 3161"
+ " document timestamp into the PDF. Only a SHA-256 hash of the"
+ " document is sent to the TSA — the PDF itself never leaves the"
+ " server. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> timestampPdf(@ModelAttribute TimestampPdfRequest request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
ApplicationProperties.Security.Timestamp tsConfig =
applicationProperties.getSecurity().getTimestamp();
// Determine effective TSA URL: use request value if provided, otherwise config default
String tsaUrl =
(request.getTsaUrl() != null && !request.getTsaUrl().isBlank())
? request.getTsaUrl()
: tsConfig.getDefaultTsaUrl();
// Build allowed set: built-in presets + admin-configured custom URLs
// Filter null/blank entries and validate protocol (TASK-6)
Set<String> allowedUrls = new HashSet<>(ALLOWED_TSA_PRESET_URLS);
if (tsConfig.getDefaultTsaUrl() != null
&& !tsConfig.getDefaultTsaUrl().isBlank()
&& isValidTsaUrlProtocol(tsConfig.getDefaultTsaUrl())) {
allowedUrls.add(tsConfig.getDefaultTsaUrl());
}
List<String> customUrls = tsConfig.getCustomTsaUrls();
if (customUrls != null) {
customUrls.stream()
.filter(u -> u != null && !u.isBlank() && isValidTsaUrlProtocol(u))
.forEach(allowedUrls::add);
}
// Normalize for case-insensitive comparison (TASK-12)
Set<String> normalizedAllowed =
allowedUrls.stream()
.map(TimestampController::normalizeTsaUrl)
.collect(Collectors.toSet());
// Validate TSA URL against allowed set to prevent SSRF
if (!normalizedAllowed.contains(normalizeTsaUrl(tsaUrl))) {
throw new IllegalArgumentException(
"TSA URL is not in the allowed list. Contact your administrator to add it"
+ " via settings.yml (security.timestamp.customTsaUrls).");
}
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try (PDDocument document = pdfDocumentFactory.load(inputFile)) {
PDSignature signature = new PDSignature();
signature.setType(COSName.DOC_TIME_STAMP);
signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
signature.setSubFilter(COSName.getPDFName("ETSI.RFC3161"));
signature.setSignDate(Calendar.getInstance());
document.addSignature(signature, content -> requestTimestampToken(content, tsaUrl));
document.saveIncremental(outputStream);
}
return WebResponseUtils.bytesToWebResponse(
outputStream.toByteArray(),
GeneralUtils.generateFilename(inputFile.getOriginalFilename(), "_timestamped.pdf"));
}
private byte[] requestTimestampToken(InputStream content, String tsaUrl) throws IOException {
HttpURLConnection connection = null;
try {
// Hash the PDF content byte range with SHA-256
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] buffer = new byte[8192];
int read;
while ((read = content.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
byte[] hash = digest.digest();
// Build the RFC 3161 timestamp request
TimeStampRequestGenerator generator = new TimeStampRequestGenerator();
generator.setCertReq(true);
BigInteger nonce = BigInteger.valueOf(SECURE_RANDOM.nextLong() & Long.MAX_VALUE);
ASN1ObjectIdentifier digestAlgorithm = NISTObjectIdentifiers.id_sha256;
TimeStampRequest tsaRequest = generator.generate(digestAlgorithm, hash, nonce);
byte[] requestBytes = tsaRequest.getEncoded();
// Contact the TSA server (redirects disabled to prevent SSRF via redirect)
connection = (HttpURLConnection) URI.create(tsaUrl).toURL().openConnection();
connection.setInstanceFollowRedirects(false);
connection.setDoOutput(true);
connection.setDoInput(true);
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/timestamp-query");
connection.setRequestProperty("Content-Length", String.valueOf(requestBytes.length));
connection.setConnectTimeout(30_000);
connection.setReadTimeout(30_000);
try (OutputStream out = connection.getOutputStream()) {
out.write(requestBytes);
}
int responseCode = connection.getResponseCode();
if (responseCode != HttpURLConnection.HTTP_OK) {
// Read error stream for debugging (TASK-5)
String errorBody = readErrorStream(connection);
throw new IOException(
"TSA server returned HTTP "
+ responseCode
+ " for URL: "
+ tsaUrl
+ (errorBody.isEmpty() ? "" : "" + errorBody));
}
// Read response with size limit to prevent OOM (TASK-4)
byte[] responseBytes;
try (InputStream in = connection.getInputStream()) {
responseBytes = in.readNBytes(MAX_TSA_RESPONSE_SIZE);
if (in.read() != -1) {
throw new IOException(
"TSA response exceeds maximum allowed size of "
+ MAX_TSA_RESPONSE_SIZE
+ " bytes");
}
}
// Parse and validate the TSA response
TimeStampResponse tsaResponse = new TimeStampResponse(responseBytes);
tsaResponse.validate(tsaRequest);
TimeStampToken token = tsaResponse.getTimeStampToken();
if (token == null) {
throw new IOException(
"TSA server did not return a timestamp token. Status: "
+ tsaResponse.getStatus());
}
log.info(
"RFC 3161 timestamp obtained from {} at {}",
tsaUrl,
token.getTimeStampInfo().getGenTime());
return token.getEncoded();
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException(
"Failed to obtain RFC 3161 timestamp from " + tsaUrl + ": " + e.getMessage(),
e);
} finally {
// Always disconnect to release the underlying socket (TASK-1)
if (connection != null) {
connection.disconnect();
}
}
}
private static boolean isValidTsaUrlProtocol(String url) {
String lower = url.toLowerCase(Locale.ROOT);
return lower.startsWith("http://") || lower.startsWith("https://");
}
private static String normalizeTsaUrl(String url) {
try {
URI uri = URI.create(url.trim());
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase(Locale.ROOT);
int port = uri.getPort();
String path = uri.getPath() == null ? "" : uri.getPath();
return scheme + "://" + host + (port == -1 ? "" : ":" + port) + path;
} catch (Exception e) {
return url.toLowerCase(Locale.ROOT);
}
}
private static String readErrorStream(HttpURLConnection connection) {
try (InputStream err = connection.getErrorStream()) {
if (err == null) return "";
byte[] body = err.readNBytes(2048);
return new String(body, StandardCharsets.UTF_8).trim();
} catch (IOException e) {
return "";
}
}
}
@@ -178,7 +178,8 @@ public class ReactRoutingController {
String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl);
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
return """
return
"""
<!doctype html>
<html>
<head>
@@ -237,7 +238,8 @@ public class ReactRoutingController {
String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl);
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
return """
return
"""
<!doctype html>
<html>
<head>
@@ -1,5 +1,7 @@
package stirling.software.SPDF.model.api;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -11,110 +11,13 @@ import stirling.software.common.model.api.PDFFile;
@EqualsAndHashCode(callSuper = true)
public class MergeMultiplePagesRequest extends PDFFile {
@Schema(
description =
"Input mode: DEFAULT uses pagesPerSheet; CUSTOM uses explicit cols x rows.",
requiredMode = Schema.RequiredMode.REQUIRED,
type = "string",
defaultValue = "DEFAULT",
allowableValues = {"DEFAULT", "CUSTOM"})
private String mode;
@Schema(
description = "The number of pages to fit onto a single sheet in the output PDF.",
type = "integer",
allowableValues = {"2", "4", "9", "16"})
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"2", "3", "4", "9", "16"})
private int pagesPerSheet = 2;
@Schema(
description =
"The arrangement of pages on the sheet: BY_ROWS fills pages row by row, while BY_COLUMNS fills pages column by column.",
type = "string",
defaultValue = "BY_ROWS",
allowableValues = {"BY_ROWS", "BY_COLUMNS"})
private String arrangement;
@Schema(
description =
"The direction in which pages are arranged on the sheet: LTR (left-to-right) or RTL (right-to-left).",
type = "string",
defaultValue = "LTR",
allowableValues = {"LTR", "RTL"})
private String readingDirection;
@Schema(
description = "Number of rows",
type = "number",
defaultValue = "1",
maximum = "300",
minimum = "1",
example = "3")
private int rows;
@Schema(
description = "Number of columns",
type = "number",
defaultValue = "2",
maximum = "300",
minimum = "1",
example = "2")
private int cols;
@Schema(
description = "The orientation of the output PDF pages",
type = "string",
defaultValue = "PORTRAIT",
allowableValues = {"PORTRAIT", "LANDSCAPE"})
private String orientation;
@Schema(
description = "Inner margin (in points) to apply around each page when merging",
type = "number",
defaultValue = "0",
minimum = "0",
example = "200")
private int innerMargin;
@Schema(
description = "Top margin (in points) to apply to the output pages when merging",
type = "number",
defaultValue = "0",
minimum = "0",
example = "200")
private int topMargin;
@Schema(
description = "Bottom margin (in points) to apply to the output pages when merging",
type = "number",
defaultValue = "0",
minimum = "0",
example = "200")
private int bottomMargin;
@Schema(
description = "Left margin (in points) to apply to the output pages when merging",
type = "number",
defaultValue = "0",
minimum = "0",
example = "200")
private int leftMargin;
@Schema(
description = "Right margin (in points) to apply to the output pages when merging",
type = "number",
defaultValue = "0",
minimum = "0",
example = "200")
private int rightMargin;
@Schema(
description = "Border width (in points) to apply around each page when merging",
type = "number",
defaultValue = "1",
minimum = "0",
example = "2")
private int borderWidth;
@Schema(description = "Boolean for if you wish to add border around the pages")
private Boolean addBorder;
}
@@ -1,24 +0,0 @@
package stirling.software.SPDF.model.api.security;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class TimestampPdfRequest extends PDFFile {
@Schema(
description =
"URL of the RFC 3161 Time Stamp Authority (TSA) server."
+ " Must be one of the built-in presets (DigiCert, Sectigo, SSL.com,"
+ " FreeTSA, MeSign) or an admin-configured URL in"
+ " settings.yml (security.timestamp.customTsaUrls)."
+ " If omitted, the server default is used.",
defaultValue = "http://timestamp.digicert.com",
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String tsaUrl;
}
@@ -581,10 +581,10 @@ public class PdfJsonFallbackFontService {
Character.UnicodeScript script = Character.UnicodeScript.of(codePoint);
return switch (script) {
// HAN script is used by both Simplified and Traditional Chinese
// Default to Simplified (mainland China, 1.4B speakers) as it's more common
// Traditional Chinese PDFs are detected via font name aliases (MingLiU, PMingLiU,
// etc.)
// HAN script is used by both Simplified and Traditional Chinese
// Default to Simplified (mainland China, 1.4B speakers) as it's more common
// Traditional Chinese PDFs are detected via font name aliases (MingLiU, PMingLiU,
// etc.)
case HAN -> FALLBACK_FONT_CJK_ID;
case HIRAGANA, KATAKANA -> FALLBACK_FONT_JP_ID;
case HANGUL -> FALLBACK_FONT_KR_ID;
@@ -1,111 +0,0 @@
package stirling.software.SPDF.service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.security.KeyStore;
import org.springframework.stereotype.Service;
import stirling.software.SPDF.controller.api.security.CertSignController;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfSigningService;
/** Core implementation of {@link PdfSigningService} backed by {@link CertSignController}. */
@Service
public class PdfSigningServiceImpl implements PdfSigningService {
private final CustomPDFDocumentFactory pdfDocumentFactory;
public PdfSigningServiceImpl(CustomPDFDocumentFactory pdfDocumentFactory) {
this.pdfDocumentFactory = pdfDocumentFactory;
}
@Override
public byte[] signWithKeystore(
byte[] pdfBytes,
KeyStore keystore,
char[] password,
boolean showSignature,
Integer pageNumber,
String name,
String location,
String reason,
boolean showLogo)
throws Exception {
CertSignController.CreateSignature createSignature =
new CertSignController.CreateSignature(keystore, password);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ByteArrayMultipartFile inputFile =
new ByteArrayMultipartFile(pdfBytes, "document.pdf", "application/pdf");
CertSignController.sign(
pdfDocumentFactory,
inputFile,
outputStream,
createSignature,
showSignature,
pageNumber,
name,
location,
reason,
showLogo);
return outputStream.toByteArray();
}
/** Minimal MultipartFile wrapper for passing raw PDF bytes to CertSignController.sign(). */
private static class ByteArrayMultipartFile
implements org.springframework.web.multipart.MultipartFile {
private final byte[] content;
private final String filename;
private final String contentType;
ByteArrayMultipartFile(byte[] content, String filename, String contentType) {
this.content = content;
this.filename = filename;
this.contentType = contentType;
}
@Override
public String getName() {
return "file";
}
@Override
public String getOriginalFilename() {
return filename;
}
@Override
public String getContentType() {
return contentType;
}
@Override
public boolean isEmpty() {
return content == null || content.length == 0;
}
@Override
public long getSize() {
return content == null ? 0 : content.length;
}
@Override
public byte[] getBytes() {
return content;
}
@Override
public java.io.InputStream getInputStream() {
return new ByteArrayInputStream(content);
}
@Override
public void transferTo(java.io.File dest) throws java.io.IOException {
java.nio.file.Files.write(dest.toPath(), content);
}
}
}
@@ -50,8 +50,6 @@ spring.mvc.problemdetails.enabled=false
# Or via SYSTEMFILEUPLOADLIMIT/SYSTEM_MAXFILESIZE which will also set fileUploadLimit in settings.yml
spring.servlet.multipart.max-file-size=${SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE:2000MB}
spring.servlet.multipart.max-request-size=${SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE:2000MB}
# Jetty max form content size (default 200KB is too small for signature images)
server.jetty.max-http-form-post-size=10MB
server.servlet.session.tracking-modes=cookie
server.servlet.context-path=${SYSTEM_ROOTURIPATH:/}
spring.devtools.restart.enabled=true
@@ -65,7 +65,7 @@ security:
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).
desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days).
allowedClockSkewSeconds: 60 # Allowed JWT validation clock skew in seconds to tolerate small client/server time drift.
refreshGraceMinutes: 15 # Allow refresh using an expired access token only within this many minutes after expiry.
validation: # PDF signature validation settings
@@ -84,9 +84,6 @@ security:
revocation:
mode: none # Revocation checking mode: 'none' (disabled), 'ocsp' (OCSP only), 'crl' (CRL only), 'ocsp+crl' (OCSP with CRL fallback)
hardFail: false # Fail validation if revocation status cannot be determined (true=strict, false=soft-fail)
timestamp:
defaultTsaUrl: http://timestamp.digicert.com # Default TSA server for RFC 3161 document timestamps
customTsaUrls: [] # Admin-configured additional TSA URLs (e.g. ['https://internal-tsa.corp.com/timestamp']). Users can only select from built-in presets and these URLs.
xFrameOptions: DENY # X-Frame-Options header value. Options: 'DENY' (default, prevents all framing), 'SAMEORIGIN' (allows framing from same domain), 'DISABLED' (no X-Frame-Options header sent). Note: automatically set to DISABLED when login is disabled
premium:
@@ -240,30 +237,6 @@ system:
databaseBackup:
cron: "0 0 0 * * ?" # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight
storage:
enabled: false # set to 'true' to allow users to store files on the server (requires security.enableLogin) [ALPHA]
provider: local # storage provider: 'local' for filesystem storage, 'database' for DB-backed storage
local:
basePath: './storage' # base directory for stored files
quotas:
maxStorageMbPerUser: -1 # Max storage per user in MB; -1 disables per-user cap
maxStorageMbTotal: -1 # Max storage across all users in MB; -1 disables total cap
maxFileMb: -1 # Max size per stored file (including history/audit) in MB; -1 disables limit
sharing:
enabled: false # set to 'true' to enable file sharing features [ALPHA]
linkEnabled: true # set to 'false' to disable share links (requires system.frontendUrl)
emailEnabled: false # set to 'true' to allow sharing by email (requires mail.enabled)
linkExpirationDays: 3 # Number of days before share links expire
signing:
enabled: false # set to 'true' to enable group signing workflow (requires storage.enabled) [ALPHA]
autoPipeline:
outputFolder: "" # Output folder for processed pipeline files (leave empty for default)
fileReadiness:
enabled: true # Set to 'false' to skip all readiness checks and process files immediately (legacy behaviour)
settleTimeMillis: 5000 # How long (ms) a file must be unmodified before it is considered fully written and stable. Default: 5000 (5 seconds)
sizeCheckDelayMillis: 500 # Pause (ms) between two file-size reads used to detect active writes (Linux/macOS mid-copy detection). Default: 500
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
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

@@ -1,70 +0,0 @@
package stirling.software.SPDF.config;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
import stirling.software.common.configuration.interfaces.ShowAdminInterface;
import stirling.software.common.model.ApplicationProperties;
class AppUpdateServiceTest {
@Test
void shouldShowWhenShowUpdateTrueAndShowAdminNull() {
ApplicationProperties props = createProps(true);
AppUpdateService service = new AppUpdateService(props, null);
assertTrue(service.shouldShow());
}
@Test
void shouldNotShowWhenShowUpdateFalse() {
ApplicationProperties props = createProps(false);
AppUpdateService service = new AppUpdateService(props, null);
assertFalse(service.shouldShow());
}
@Test
void shouldShowWhenShowUpdateTrueAndAdminReturnsTrue() {
ApplicationProperties props = createProps(true);
ShowAdminInterface showAdmin = mock(ShowAdminInterface.class);
when(showAdmin.getShowUpdateOnlyAdmins()).thenReturn(true);
AppUpdateService service = new AppUpdateService(props, showAdmin);
assertTrue(service.shouldShow());
}
@Test
void shouldNotShowWhenShowUpdateTrueAndAdminReturnsFalse() {
ApplicationProperties props = createProps(true);
ShowAdminInterface showAdmin = mock(ShowAdminInterface.class);
when(showAdmin.getShowUpdateOnlyAdmins()).thenReturn(false);
AppUpdateService service = new AppUpdateService(props, showAdmin);
assertFalse(service.shouldShow());
}
@Test
void shouldNotShowWhenShowUpdateFalseAndAdminReturnsTrue() {
ApplicationProperties props = createProps(false);
ShowAdminInterface showAdmin = mock(ShowAdminInterface.class);
when(showAdmin.getShowUpdateOnlyAdmins()).thenReturn(true);
AppUpdateService service = new AppUpdateService(props, showAdmin);
assertFalse(service.shouldShow());
}
@Test
void shouldNotShowWhenBothFalse() {
ApplicationProperties props = createProps(false);
ShowAdminInterface showAdmin = mock(ShowAdminInterface.class);
when(showAdmin.getShowUpdateOnlyAdmins()).thenReturn(false);
AppUpdateService service = new AppUpdateService(props, showAdmin);
assertFalse(service.shouldShow());
}
private ApplicationProperties createProps(boolean showUpdate) {
ApplicationProperties props = new ApplicationProperties();
ApplicationProperties.System system = new ApplicationProperties.System();
system.setShowUpdate(showUpdate);
props.setSystem(system);
return props;
}
}
@@ -1,99 +0,0 @@
package stirling.software.SPDF.config;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
class CleanUrlInterceptorTest {
private CleanUrlInterceptor interceptor;
private HttpServletRequest request;
private HttpServletResponse response;
@BeforeEach
void setUp() {
interceptor = new CleanUrlInterceptor();
request = mock(HttpServletRequest.class);
response = mock(HttpServletResponse.class);
}
@Test
void preHandleAllowsApiEndpoints() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/some-endpoint");
when(request.getQueryString()).thenReturn("foo=bar&baz=qux");
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleAllowsRequestWithNoQueryString() throws Exception {
when(request.getRequestURI()).thenReturn("/some-page");
when(request.getQueryString()).thenReturn(null);
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleAllowsEmptyQueryString() throws Exception {
when(request.getRequestURI()).thenReturn("/some-page");
when(request.getQueryString()).thenReturn("");
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleAllowsOnlyAllowedParams() throws Exception {
when(request.getRequestURI()).thenReturn("/some-page");
when(request.getQueryString()).thenReturn("lang=en");
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleRedirectsWhenDisallowedParamsPresent() throws Exception {
when(request.getRequestURI()).thenReturn("/some-page");
when(request.getContextPath()).thenReturn("");
when(request.getQueryString()).thenReturn("lang=en&evil=malicious");
assertFalse(interceptor.preHandle(request, response, new Object()));
verify(response).sendRedirect(contains("lang=en"));
}
@Test
void preHandleRedirectsStrippingAllDisallowedParams() throws Exception {
when(request.getRequestURI()).thenReturn("/page");
when(request.getContextPath()).thenReturn("/ctx");
when(request.getQueryString()).thenReturn("unknown=bad");
assertFalse(interceptor.preHandle(request, response, new Object()));
verify(response).sendRedirect(eq("/ctx/page?"));
}
@Test
void preHandleAllowsMultipleAllowedParams() throws Exception {
when(request.getRequestURI()).thenReturn("/page");
when(request.getQueryString()).thenReturn("lang=en&endpoint=test&page=1");
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleSkipsParamsWithNoEqualsSign() throws Exception {
when(request.getRequestURI()).thenReturn("/page");
when(request.getContextPath()).thenReturn("");
when(request.getQueryString()).thenReturn("lang=en&malformed");
// "malformed" has no '=', so keyValuePair.length != 2 -> skipped
// allowedParameters has 1 entry (lang=en) but queryParameters.length is 2
// So it redirects
assertFalse(interceptor.preHandle(request, response, new Object()));
}
@Test
void postHandleDoesNotThrow() {
assertDoesNotThrow(() -> interceptor.postHandle(request, response, new Object(), null));
}
@Test
void afterCompletionDoesNotThrow() {
assertDoesNotThrow(
() -> interceptor.afterCompletion(request, response, new Object(), null));
}
}
@@ -1,115 +0,0 @@
package stirling.software.SPDF.config;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
class EndpointInspectorTest {
private ApplicationContext applicationContext;
private EndpointInspector inspector;
@BeforeEach
void setUp() {
applicationContext = mock(ApplicationContext.class);
inspector = new EndpointInspector(applicationContext);
}
@Test
void isValidGetEndpointReturnsTrueForExactMatch() throws Exception {
addEndpoints("/home", "/about");
assertTrue(inspector.isValidGetEndpoint("/home"));
}
@Test
void isValidGetEndpointReturnsFalseForUnknownEndpoint() throws Exception {
addEndpoints("/home");
assertFalse(inspector.isValidGetEndpoint("/unknown"));
}
@Test
void isValidGetEndpointMatchesWildcardPattern() throws Exception {
addEndpoints("/api/**");
assertTrue(inspector.isValidGetEndpoint("/api/v1/test"));
}
@Test
void isValidGetEndpointMatchesPathVariablePattern() throws Exception {
addEndpoints("/users/{id}");
assertTrue(inspector.isValidGetEndpoint("/users/123"));
}
@Test
void isValidGetEndpointMatchesPathSegments() throws Exception {
addEndpoints("/api/v1/convert");
assertTrue(inspector.isValidGetEndpoint("/api/v1/convert/extra"));
}
@Test
void isValidGetEndpointReturnsFalseForPartialNonMatch() throws Exception {
addEndpoints("/api/v1/convert");
assertFalse(inspector.isValidGetEndpoint("/other/path"));
}
@Test
void getValidGetEndpointsReturnsDefensiveCopy() throws Exception {
addEndpoints("/home");
Set<String> first = inspector.getValidGetEndpoints();
Set<String> second = inspector.getValidGetEndpoints();
assertEquals(first, second);
assertNotSame(first, second);
}
@Test
void discoverEndpointsAddsFallbackWhenNoMappingsFound() {
when(applicationContext.getBeansOfType(RequestMappingHandlerMapping.class))
.thenReturn(new HashMap<>());
Set<String> endpoints = inspector.getValidGetEndpoints();
assertTrue(endpoints.contains("/"));
assertTrue(endpoints.contains("/**"));
}
@Test
void wildcardPatternDoesNotMatchDifferentPrefix() throws Exception {
addEndpoints("/admin/*");
assertFalse(inspector.isValidGetEndpoint("/user/test"));
}
@Test
void pathVariableWithDifferentPrefixDoesNotMatch() throws Exception {
addEndpoints("/orders/{id}");
assertFalse(inspector.isValidGetEndpoint("/products/123"));
}
/**
* Helper to inject endpoints directly into the inspector's validGetEndpoints field and mark
* endpoints as discovered.
*/
private void addEndpoints(String... endpoints) throws Exception {
// First trigger discovery with empty context so fallback doesn't interfere
when(applicationContext.getBeansOfType(RequestMappingHandlerMapping.class))
.thenReturn(new HashMap<>());
Field validGetEndpointsField =
EndpointInspector.class.getDeclaredField("validGetEndpoints");
validGetEndpointsField.setAccessible(true);
@SuppressWarnings("unchecked")
Set<String> set = (Set<String>) validGetEndpointsField.get(inspector);
set.clear();
for (String ep : endpoints) {
set.add(ep);
}
Field discoveredField = EndpointInspector.class.getDeclaredField("endpointsDiscovered");
discoveredField.setAccessible(true);
discoveredField.setBoolean(inspector, true);
}
}
@@ -1,86 +0,0 @@
package stirling.software.SPDF.config;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@ExtendWith(MockitoExtension.class)
class EndpointInterceptorTest {
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private HttpServletRequest request;
@Mock private HttpServletResponse response;
private EndpointInterceptor interceptor;
@BeforeEach
void setUp() {
interceptor = new EndpointInterceptor(endpointConfiguration);
}
@Test
void preHandleAllowsEnabledApiEndpoint() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/general/remove-pages");
when(endpointConfiguration.isEndpointEnabled("remove-pages")).thenReturn(true);
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleBlocksDisabledApiEndpoint() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/general/remove-pages");
when(endpointConfiguration.isEndpointEnabled("remove-pages")).thenReturn(false);
assertFalse(interceptor.preHandle(request, response, new Object()));
verify(response).sendError(HttpServletResponse.SC_FORBIDDEN, "This endpoint is disabled");
}
@Test
void preHandleExtractsConvertEndpointCorrectly() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/convert/pdf/img");
when(endpointConfiguration.isEndpointEnabled("pdf-to-img")).thenReturn(true);
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleBlocksDisabledConvertEndpoint() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/convert/pdf/img");
when(endpointConfiguration.isEndpointEnabled("pdf-to-img")).thenReturn(false);
assertFalse(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleUsesFullUriForNonApiPaths() throws Exception {
when(request.getRequestURI()).thenReturn("/some-page");
when(endpointConfiguration.isEndpointEnabled("/some-page")).thenReturn(true);
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleBlocksDisabledNonApiPath() throws Exception {
when(request.getRequestURI()).thenReturn("/some-page");
when(endpointConfiguration.isEndpointEnabled("/some-page")).thenReturn(false);
assertFalse(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleUsesFullUriForShortApiPath() throws Exception {
// URI with /api/v1 but not enough segments (split length <= 4)
when(request.getRequestURI()).thenReturn("/api/v1/general");
when(endpointConfiguration.isEndpointEnabled("/api/v1/general")).thenReturn(true);
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleExtractsNonConvertApiEndpoint() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/security/add-watermark");
when(endpointConfiguration.isEndpointEnabled("add-watermark")).thenReturn(true);
assertTrue(interceptor.preHandle(request, response, new Object()));
}
}
@@ -1,145 +0,0 @@
package stirling.software.SPDF.config;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.PathItem;
import io.swagger.v3.oas.models.Paths;
import io.swagger.v3.oas.models.responses.ApiResponses;
class GlobalErrorResponseCustomizerTest {
private GlobalErrorResponseCustomizer customizer;
@BeforeEach
void setUp() {
customizer = new GlobalErrorResponseCustomizer();
}
@Test
void customiseAddsErrorResponsesToApiV1PostOperation() {
OpenAPI openApi = createOpenApiWithOperation("/api/v1/test", "post");
customizer.customise(openApi);
ApiResponses responses = openApi.getPaths().get("/api/v1/test").getPost().getResponses();
assertTrue(responses.containsKey("400"));
assertTrue(responses.containsKey("413"));
assertTrue(responses.containsKey("422"));
assertTrue(responses.containsKey("500"));
}
@Test
void customiseAddsErrorResponsesToGetOperation() {
OpenAPI openApi = createOpenApiWithOperation("/api/v1/test", "get");
customizer.customise(openApi);
ApiResponses responses = openApi.getPaths().get("/api/v1/test").getGet().getResponses();
assertTrue(responses.containsKey("400"));
}
@Test
void customiseDoesNotModifyNonApiPaths() {
OpenAPI openApi = createOpenApiWithOperation("/other/path", "post");
customizer.customise(openApi);
ApiResponses responses = openApi.getPaths().get("/other/path").getPost().getResponses();
assertFalse(responses.containsKey("400"));
}
@Test
void customiseSkipsNullPaths() {
OpenAPI openApi = new OpenAPI();
assertDoesNotThrow(() -> customizer.customise(openApi));
}
@Test
void customiseDoesNotOverwriteExistingErrorResponses() {
OpenAPI openApi = createOpenApiWithOperation("/api/v1/test", "post");
io.swagger.v3.oas.models.responses.ApiResponse custom400 =
new io.swagger.v3.oas.models.responses.ApiResponse().description("Custom 400");
openApi.getPaths()
.get("/api/v1/test")
.getPost()
.getResponses()
.addApiResponse("400", custom400);
customizer.customise(openApi);
assertEquals(
"Custom 400",
openApi.getPaths()
.get("/api/v1/test")
.getPost()
.getResponses()
.get("400")
.getDescription());
}
@Test
void customiseHandlesPutPatchDelete() {
OpenAPI openApi = new OpenAPI();
Paths paths = new Paths();
PathItem pathItem = new PathItem();
Operation put = new Operation();
put.setResponses(new ApiResponses());
pathItem.setPut(put);
Operation patch = new Operation();
patch.setResponses(new ApiResponses());
pathItem.setPatch(patch);
Operation delete = new Operation();
delete.setResponses(new ApiResponses());
pathItem.setDelete(delete);
paths.addPathItem("/api/v1/resource", pathItem);
openApi.setPaths(paths);
customizer.customise(openApi);
assertTrue(put.getResponses().containsKey("400"));
assertTrue(patch.getResponses().containsKey("413"));
assertTrue(delete.getResponses().containsKey("500"));
}
@Test
void customiseSkipsOperationWithNullResponses() {
OpenAPI openApi = new OpenAPI();
Paths paths = new Paths();
PathItem pathItem = new PathItem();
Operation post = new Operation();
// responses is null
pathItem.setPost(post);
paths.addPathItem("/api/v1/test", pathItem);
openApi.setPaths(paths);
assertDoesNotThrow(() -> customizer.customise(openApi));
}
@Test
void errorResponseDescriptionsAreCorrect() {
OpenAPI openApi = createOpenApiWithOperation("/api/v1/test", "post");
customizer.customise(openApi);
ApiResponses responses = openApi.getPaths().get("/api/v1/test").getPost().getResponses();
assertTrue(responses.get("400").getDescription().contains("Bad request"));
assertTrue(responses.get("413").getDescription().contains("Payload too large"));
assertTrue(responses.get("422").getDescription().contains("Unprocessable entity"));
assertTrue(responses.get("500").getDescription().contains("Internal server error"));
}
private OpenAPI createOpenApiWithOperation(String path, String method) {
OpenAPI openApi = new OpenAPI();
Paths paths = new Paths();
PathItem pathItem = new PathItem();
Operation operation = new Operation();
operation.setResponses(new ApiResponses());
switch (method) {
case "post" -> pathItem.setPost(operation);
case "get" -> pathItem.setGet(operation);
case "put" -> pathItem.setPut(operation);
case "delete" -> pathItem.setDelete(operation);
}
paths.addPathItem(path, pathItem);
openApi.setPaths(paths);
return openApi;
}
}
@@ -1,78 +0,0 @@
package stirling.software.SPDF.config;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
import org.springframework.web.servlet.i18n.SessionLocaleResolver;
import stirling.software.common.model.ApplicationProperties;
class LocaleConfigurationTest {
@Test
void localeChangeInterceptorUsesLangParam() {
LocaleConfiguration config = createConfig(null);
LocaleChangeInterceptor lci = config.localeChangeInterceptor();
assertEquals("lang", lci.getParamName());
}
@Test
void localeResolverDefaultsToUKWhenNoLocaleConfigured() {
LocaleConfiguration config = createConfig(null);
LocaleResolver resolver = config.localeResolver();
assertNotNull(resolver);
assertTrue(resolver instanceof SessionLocaleResolver);
}
@Test
void localeResolverDefaultsToUKWhenEmptyLocale() {
LocaleConfiguration config = createConfig("");
LocaleResolver resolver = config.localeResolver();
assertNotNull(resolver);
}
@Test
void localeResolverAcceptsValidLocale() {
LocaleConfiguration config = createConfig("de-DE");
LocaleResolver resolver = config.localeResolver();
assertNotNull(resolver);
}
@Test
void localeResolverHandlesUnderscoreLocale() {
LocaleConfiguration config = createConfig("fr_FR");
LocaleResolver resolver = config.localeResolver();
assertNotNull(resolver);
}
@Test
void localeResolverFallsBackForInvalidLocale() {
// An invalid tag that doesn't round-trip
LocaleConfiguration config = createConfig("invalid!!locale");
LocaleResolver resolver = config.localeResolver();
assertNotNull(resolver);
}
@Test
void localeChangeInterceptorIsNotNull() {
LocaleConfiguration config = createConfig("en-US");
assertNotNull(config.localeChangeInterceptor());
}
@Test
void localeResolverHandlesJapaneseLocale() {
LocaleConfiguration config = createConfig("ja-JP");
LocaleResolver resolver = config.localeResolver();
assertNotNull(resolver);
}
private LocaleConfiguration createConfig(String locale) {
ApplicationProperties props = new ApplicationProperties();
ApplicationProperties.System system = new ApplicationProperties.System();
system.setDefaultLocale(locale);
props.setSystem(system);
return new LocaleConfiguration(props);
}
}
@@ -1,31 +0,0 @@
package stirling.software.SPDF.config;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import stirling.software.common.configuration.InstallationPathConfig;
class LogbackPropertyLoaderTest {
@Test
void getPropertyValueReturnsLogPath() {
LogbackPropertyLoader loader = new LogbackPropertyLoader();
String result = loader.getPropertyValue();
assertEquals(InstallationPathConfig.getLogPath(), result);
}
@Test
void getPropertyValueIsNotNull() {
LogbackPropertyLoader loader = new LogbackPropertyLoader();
assertNotNull(loader.getPropertyValue());
}
@Test
void getPropertyValueIsConsistentAcrossCalls() {
LogbackPropertyLoader loader = new LogbackPropertyLoader();
String first = loader.getPropertyValue();
String second = loader.getPropertyValue();
assertEquals(first, second);
}
}
@@ -1,353 +0,0 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.util.*;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
import org.apache.pdfbox.pdmodel.encryption.PDEncryption;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
@ExtendWith(MockitoExtension.class)
class AnalysisControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@InjectMocks private AnalysisController analysisController;
private MockMultipartFile mockFile;
@BeforeEach
void setUp() {
mockFile =
new MockMultipartFile(
"fileInput", "test.pdf", "application/pdf", "fake-pdf".getBytes());
}
private PDFFile createRequest() {
PDFFile request = new PDFFile();
request.setFileInput(mockFile);
return request;
}
// --- getPageCount ---
@Test
void getPageCount_returnsCorrectCount() throws IOException {
PDFFile request = createRequest();
PDDocument doc = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile)).thenReturn(doc);
when(doc.getNumberOfPages()).thenReturn(5);
ResponseEntity<?> response = analysisController.getPageCount(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertThat(body).containsEntry("pageCount", 5);
verify(doc).close();
}
@Test
void getPageCount_emptyDocument() throws IOException {
PDFFile request = createRequest();
PDDocument doc = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile)).thenReturn(doc);
when(doc.getNumberOfPages()).thenReturn(0);
ResponseEntity<?> response = analysisController.getPageCount(request);
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertThat(body).containsEntry("pageCount", 0);
}
@Test
void getPageCount_ioException() throws IOException {
PDFFile request = createRequest();
when(pdfDocumentFactory.load(mockFile)).thenThrow(new IOException("corrupt"));
assertThatThrownBy(() -> analysisController.getPageCount(request))
.isInstanceOf(IOException.class);
}
// --- getBasicInfo ---
@Test
void getBasicInfo_returnsAllFields() throws IOException {
PDFFile request = createRequest();
PDDocument doc = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile)).thenReturn(doc);
when(doc.getNumberOfPages()).thenReturn(3);
when(doc.getVersion()).thenReturn(1.7f);
ResponseEntity<?> response = analysisController.getBasicInfo(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertThat(body).containsEntry("pageCount", 3);
assertThat(body).containsEntry("pdfVersion", 1.7f);
assertThat(body).containsKey("fileSize");
}
// --- getDocumentProperties ---
@Test
void getDocumentProperties_returnsMetadata() throws IOException {
PDFFile request = createRequest();
PDDocument doc = mock(PDDocument.class);
PDDocumentInformation info = mock(PDDocumentInformation.class);
when(pdfDocumentFactory.load(mockFile, true)).thenReturn(doc);
when(doc.getDocumentInformation()).thenReturn(info);
when(info.getTitle()).thenReturn("Test Title");
when(info.getAuthor()).thenReturn("Author");
when(info.getSubject()).thenReturn("Subject");
when(info.getKeywords()).thenReturn("key1,key2");
when(info.getCreator()).thenReturn("Creator");
when(info.getProducer()).thenReturn("Producer");
when(info.getCreationDate()).thenReturn(null);
when(info.getModificationDate()).thenReturn(null);
ResponseEntity<?> response = analysisController.getDocumentProperties(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, String> body = (Map<String, String>) response.getBody();
assertThat(body).containsEntry("title", "Test Title");
assertThat(body).containsEntry("author", "Author");
}
@Test
void getDocumentProperties_nullValues() throws IOException {
PDFFile request = createRequest();
PDDocument doc = mock(PDDocument.class);
PDDocumentInformation info = mock(PDDocumentInformation.class);
when(pdfDocumentFactory.load(mockFile, true)).thenReturn(doc);
when(doc.getDocumentInformation()).thenReturn(info);
ResponseEntity<?> response = analysisController.getDocumentProperties(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, String> body = (Map<String, String>) response.getBody();
assertThat(body.get("title")).isNull();
}
// --- getPageDimensions ---
@Test
void getPageDimensions_multiplePages() throws IOException {
PDFFile request = createRequest();
PDDocument doc = mock(PDDocument.class);
PDPageTree pages = mock(PDPageTree.class);
PDPage page1 = mock(PDPage.class);
PDPage page2 = mock(PDPage.class);
when(pdfDocumentFactory.load(mockFile)).thenReturn(doc);
when(doc.getPages()).thenReturn(pages);
when(pages.iterator()).thenReturn(List.of(page1, page2).iterator());
when(page1.getBBox()).thenReturn(new PDRectangle(612, 792));
when(page2.getBBox()).thenReturn(new PDRectangle(842, 595));
ResponseEntity<?> response = analysisController.getPageDimensions(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
List<Map<String, Float>> body = (List<Map<String, Float>>) response.getBody();
assertThat(body).hasSize(2);
assertThat(body.get(0)).containsEntry("width", 612f);
assertThat(body.get(1)).containsEntry("width", 842f);
}
// --- getFormFields ---
@Test
void getFormFields_withForm() throws IOException {
PDFFile request = createRequest();
PDDocument doc = mock(PDDocument.class);
PDDocumentCatalog catalog = mock(PDDocumentCatalog.class);
PDAcroForm form = mock(PDAcroForm.class);
when(pdfDocumentFactory.load(mockFile)).thenReturn(doc);
when(doc.getDocumentCatalog()).thenReturn(catalog);
when(catalog.getAcroForm()).thenReturn(form);
when(form.getFields()).thenReturn(List.of());
when(form.hasXFA()).thenReturn(false);
when(form.isSignaturesExist()).thenReturn(true);
ResponseEntity<?> response = analysisController.getFormFields(request);
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertThat(body).containsEntry("fieldCount", 0);
assertThat(body).containsEntry("hasXFA", false);
assertThat(body).containsEntry("isSignaturesExist", true);
}
@Test
void getFormFields_noForm() throws IOException {
PDFFile request = createRequest();
PDDocument doc = mock(PDDocument.class);
PDDocumentCatalog catalog = mock(PDDocumentCatalog.class);
when(pdfDocumentFactory.load(mockFile)).thenReturn(doc);
when(doc.getDocumentCatalog()).thenReturn(catalog);
when(catalog.getAcroForm()).thenReturn(null);
ResponseEntity<?> response = analysisController.getFormFields(request);
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertThat(body).containsEntry("fieldCount", 0);
assertThat(body).containsEntry("hasXFA", false);
assertThat(body).containsEntry("isSignaturesExist", false);
}
// --- getAnnotationInfo ---
@Test
void getAnnotationInfo_withAnnotations() throws IOException {
PDFFile request = createRequest();
PDDocument doc = mock(PDDocument.class);
PDPageTree pages = mock(PDPageTree.class);
PDPage page = mock(PDPage.class);
PDAnnotation annot = mock(PDAnnotation.class);
when(pdfDocumentFactory.load(mockFile)).thenReturn(doc);
when(doc.getPages()).thenReturn(pages);
when(pages.iterator()).thenReturn(List.of(page).iterator());
when(page.getAnnotations()).thenReturn(List.of(annot));
when(annot.getSubtype()).thenReturn("Link");
ResponseEntity<?> response = analysisController.getAnnotationInfo(request);
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertThat(body).containsEntry("totalCount", 1);
@SuppressWarnings("unchecked")
Map<String, Integer> types = (Map<String, Integer>) body.get("typeBreakdown");
assertThat(types).containsEntry("Link", 1);
}
@Test
void getAnnotationInfo_noAnnotations() throws IOException {
PDFFile request = createRequest();
PDDocument doc = mock(PDDocument.class);
PDPageTree pages = mock(PDPageTree.class);
PDPage page = mock(PDPage.class);
when(pdfDocumentFactory.load(mockFile)).thenReturn(doc);
when(doc.getPages()).thenReturn(pages);
when(pages.iterator()).thenReturn(List.of(page).iterator());
when(page.getAnnotations()).thenReturn(List.of());
ResponseEntity<?> response = analysisController.getAnnotationInfo(request);
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertThat(body).containsEntry("totalCount", 0);
}
// --- getFontInfo ---
@Test
void getFontInfo_withFonts() throws IOException {
PDFFile request = createRequest();
PDDocument doc = mock(PDDocument.class);
PDPageTree pages = mock(PDPageTree.class);
PDPage page = mock(PDPage.class);
PDResources resources = mock(PDResources.class);
when(pdfDocumentFactory.load(mockFile)).thenReturn(doc);
when(doc.getPages()).thenReturn(pages);
when(pages.iterator()).thenReturn(List.of(page).iterator());
when(page.getResources()).thenReturn(resources);
when(resources.getFontNames())
.thenReturn(Set.of(COSName.getPDFName("F1"), COSName.getPDFName("F2")));
ResponseEntity<?> response = analysisController.getFontInfo(request);
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertThat(body).containsEntry("fontCount", 2);
}
@Test
void getFontInfo_noResources() throws IOException {
PDFFile request = createRequest();
PDDocument doc = mock(PDDocument.class);
PDPageTree pages = mock(PDPageTree.class);
PDPage page = mock(PDPage.class);
when(pdfDocumentFactory.load(mockFile)).thenReturn(doc);
when(doc.getPages()).thenReturn(pages);
when(pages.iterator()).thenReturn(List.of(page).iterator());
when(page.getResources()).thenReturn(null);
ResponseEntity<?> response = analysisController.getFontInfo(request);
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertThat(body).containsEntry("fontCount", 0);
}
// --- getSecurityInfo ---
@Test
void getSecurityInfo_encrypted() throws IOException {
PDFFile request = createRequest();
PDDocument doc = mock(PDDocument.class);
PDEncryption encryption = mock(PDEncryption.class);
AccessPermission perm = mock(AccessPermission.class);
when(pdfDocumentFactory.load(mockFile)).thenReturn(doc);
when(doc.getEncryption()).thenReturn(encryption);
when(encryption.getLength()).thenReturn(128);
when(doc.getCurrentAccessPermission()).thenReturn(perm);
when(perm.canPrint()).thenReturn(false);
when(perm.canModify()).thenReturn(true);
when(perm.canExtractContent()).thenReturn(true);
when(perm.canModifyAnnotations()).thenReturn(false);
ResponseEntity<?> response = analysisController.getSecurityInfo(request);
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertThat(body).containsEntry("isEncrypted", true);
assertThat(body).containsEntry("keyLength", 128);
@SuppressWarnings("unchecked")
Map<String, Boolean> perms = (Map<String, Boolean>) body.get("permissions");
assertThat(perms).containsEntry("preventPrinting", true);
assertThat(perms).containsEntry("preventModify", false);
}
@Test
void getSecurityInfo_notEncrypted() throws IOException {
PDFFile request = createRequest();
PDDocument doc = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile)).thenReturn(doc);
when(doc.getEncryption()).thenReturn(null);
ResponseEntity<?> response = analysisController.getSecurityInfo(request);
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertThat(body).containsEntry("isEncrypted", false);
assertThat(body).doesNotContainKey("permissions");
}
}
@@ -1,227 +0,0 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.general.BookletImpositionRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@ExtendWith(MockitoExtension.class)
class BookletImpositionControllerTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@InjectMocks private BookletImpositionController controller;
private MockMultipartFile createRealPdf(int numPages) throws IOException {
Path path = tempDir.resolve("test.pdf");
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < numPages; i++) {
doc.addPage(new PDPage(PDRectangle.LETTER));
}
doc.save(path.toFile());
}
return new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, Files.readAllBytes(path));
}
private BookletImpositionRequest createRequest(MockMultipartFile file) {
BookletImpositionRequest req = new BookletImpositionRequest();
req.setFileInput(file);
req.setPagesPerSheet(2);
return req;
}
@Test
void createBookletImposition_basicSuccess() throws IOException {
MockMultipartFile file = createRealPdf(4);
BookletImpositionRequest request = createRequest(file);
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
PDDocument newDoc = new PDDocument();
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc)).thenReturn(newDoc);
ResponseEntity<byte[]> response = controller.createBookletImposition(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotEmpty();
try (PDDocument result = Loader.loadPDF(response.getBody())) {
assertThat(result.getNumberOfPages()).isGreaterThan(0);
}
}
@Test
void createBookletImposition_invalidPagesPerSheet() throws IOException {
MockMultipartFile file = createRealPdf(4);
BookletImpositionRequest request = createRequest(file);
request.setPagesPerSheet(4);
assertThatThrownBy(() -> controller.createBookletImposition(request))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("2 pages per side");
}
@Test
void createBookletImposition_withBorder() throws IOException {
MockMultipartFile file = createRealPdf(4);
BookletImpositionRequest request = createRequest(file);
request.setAddBorder(true);
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
PDDocument newDoc = new PDDocument();
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc)).thenReturn(newDoc);
ResponseEntity<byte[]> response = controller.createBookletImposition(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotEmpty();
}
@Test
void createBookletImposition_rightSpine() throws IOException {
MockMultipartFile file = createRealPdf(4);
BookletImpositionRequest request = createRequest(file);
request.setSpineLocation("RIGHT");
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
PDDocument newDoc = new PDDocument();
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc)).thenReturn(newDoc);
ResponseEntity<byte[]> response = controller.createBookletImposition(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void createBookletImposition_withGutter() throws IOException {
MockMultipartFile file = createRealPdf(4);
BookletImpositionRequest request = createRequest(file);
request.setAddGutter(true);
request.setGutterSize(20f);
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
PDDocument newDoc = new PDDocument();
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc)).thenReturn(newDoc);
ResponseEntity<byte[]> response = controller.createBookletImposition(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void createBookletImposition_doubleSidedFirstPass() throws IOException {
MockMultipartFile file = createRealPdf(8);
BookletImpositionRequest request = createRequest(file);
request.setDoubleSided(true);
request.setDuplexPass("FIRST");
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
PDDocument newDoc = new PDDocument();
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc)).thenReturn(newDoc);
ResponseEntity<byte[]> response = controller.createBookletImposition(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void createBookletImposition_doubleSidedSecondPass() throws IOException {
MockMultipartFile file = createRealPdf(8);
BookletImpositionRequest request = createRequest(file);
request.setDoubleSided(true);
request.setDuplexPass("SECOND");
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
PDDocument newDoc = new PDDocument();
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc)).thenReturn(newDoc);
ResponseEntity<byte[]> response = controller.createBookletImposition(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void createBookletImposition_flipOnShortEdge() throws IOException {
MockMultipartFile file = createRealPdf(4);
BookletImpositionRequest request = createRequest(file);
request.setDoubleSided(true);
request.setFlipOnShortEdge(true);
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
PDDocument newDoc = new PDDocument();
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc)).thenReturn(newDoc);
ResponseEntity<byte[]> response = controller.createBookletImposition(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void createBookletImposition_singlePage() throws IOException {
MockMultipartFile file = createRealPdf(1);
BookletImpositionRequest request = createRequest(file);
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
PDDocument newDoc = new PDDocument();
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc)).thenReturn(newDoc);
ResponseEntity<byte[]> response = controller.createBookletImposition(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void createBookletImposition_ioException() throws IOException {
MockMultipartFile file = createRealPdf(4);
BookletImpositionRequest request = createRequest(file);
when(pdfDocumentFactory.load(file)).thenThrow(new IOException("load error"));
assertThatThrownBy(() -> controller.createBookletImposition(request))
.isInstanceOf(IOException.class);
}
@Test
void createBookletImposition_negativeGutterClamped() throws IOException {
MockMultipartFile file = createRealPdf(4);
BookletImpositionRequest request = createRequest(file);
request.setAddGutter(true);
request.setGutterSize(-10f);
PDDocument sourceDoc = Loader.loadPDF(file.getBytes());
PDDocument newDoc = new PDDocument();
when(pdfDocumentFactory.load(file)).thenReturn(sourceDoc);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc)).thenReturn(newDoc);
ResponseEntity<byte[]> response = controller.createBookletImposition(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
@@ -106,9 +106,7 @@ class MultiPageLayoutControllerTest {
.thenReturn(target);
MergeMultiplePagesRequest req = new MergeMultiplePagesRequest();
req.setMode("CUSTOM");
req.setCols(3);
req.setRows(1);
req.setPagesPerSheet(3);
req.setAddBorder(Boolean.TRUE);
req.setFileInput(fileNoExt);
@@ -1,299 +0,0 @@
package stirling.software.SPDF.controller.api;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Path;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.model.api.general.OverlayPdfsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@ExtendWith(MockitoExtension.class)
class PdfOverlayControllerTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@InjectMocks private PdfOverlayController controller;
private byte[] createPdf(int numPages) throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < numPages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
@Test
@DisplayName("Should overlay with SequentialOverlay mode")
void testSequentialOverlay() throws Exception {
byte[] baseBytes = createPdf(2);
byte[] overlayBytes = createPdf(2);
MockMultipartFile baseFile =
new MockMultipartFile(
"fileInput", "base.pdf", MediaType.APPLICATION_PDF_VALUE, baseBytes);
MockMultipartFile overlayFile =
new MockMultipartFile(
"overlayFile",
"overlay.pdf",
MediaType.APPLICATION_PDF_VALUE,
overlayBytes);
OverlayPdfsRequest request = new OverlayPdfsRequest();
request.setFileInput(baseFile);
request.setOverlayFiles(new MultipartFile[] {overlayFile});
request.setOverlayMode("SequentialOverlay");
request.setOverlayPosition(0);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
ResponseEntity<byte[]> response = controller.overlayPdfs(request);
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertTrue(response.getBody().length > 0);
}
@Test
@DisplayName("Should overlay with InterleavedOverlay mode")
void testInterleavedOverlay() throws Exception {
byte[] baseBytes = createPdf(3);
byte[] overlay1Bytes = createPdf(1);
byte[] overlay2Bytes = createPdf(1);
MockMultipartFile baseFile =
new MockMultipartFile(
"fileInput", "base.pdf", MediaType.APPLICATION_PDF_VALUE, baseBytes);
MockMultipartFile overlay1 =
new MockMultipartFile(
"overlay1", "overlay1.pdf", MediaType.APPLICATION_PDF_VALUE, overlay1Bytes);
MockMultipartFile overlay2 =
new MockMultipartFile(
"overlay2", "overlay2.pdf", MediaType.APPLICATION_PDF_VALUE, overlay2Bytes);
OverlayPdfsRequest request = new OverlayPdfsRequest();
request.setFileInput(baseFile);
request.setOverlayFiles(new MultipartFile[] {overlay1, overlay2});
request.setOverlayMode("InterleavedOverlay");
request.setOverlayPosition(0);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
ResponseEntity<byte[]> response = controller.overlayPdfs(request);
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
@DisplayName("Should overlay with FixedRepeatOverlay mode")
void testFixedRepeatOverlay() throws Exception {
byte[] baseBytes = createPdf(4);
byte[] overlayBytes = createPdf(1);
MockMultipartFile baseFile =
new MockMultipartFile(
"fileInput", "base.pdf", MediaType.APPLICATION_PDF_VALUE, baseBytes);
MockMultipartFile overlayFile =
new MockMultipartFile(
"overlayFile",
"overlay.pdf",
MediaType.APPLICATION_PDF_VALUE,
overlayBytes);
OverlayPdfsRequest request = new OverlayPdfsRequest();
request.setFileInput(baseFile);
request.setOverlayFiles(new MultipartFile[] {overlayFile});
request.setOverlayMode("FixedRepeatOverlay");
request.setOverlayPosition(0);
request.setCounts(new int[] {4});
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
ResponseEntity<byte[]> response = controller.overlayPdfs(request);
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
@DisplayName("Should use background position when overlayPosition is 1")
void testBackgroundOverlayPosition() throws Exception {
byte[] baseBytes = createPdf(1);
byte[] overlayBytes = createPdf(1);
MockMultipartFile baseFile =
new MockMultipartFile(
"fileInput", "base.pdf", MediaType.APPLICATION_PDF_VALUE, baseBytes);
MockMultipartFile overlayFile =
new MockMultipartFile(
"overlayFile",
"overlay.pdf",
MediaType.APPLICATION_PDF_VALUE,
overlayBytes);
OverlayPdfsRequest request = new OverlayPdfsRequest();
request.setFileInput(baseFile);
request.setOverlayFiles(new MultipartFile[] {overlayFile});
request.setOverlayMode("InterleavedOverlay");
request.setOverlayPosition(1); // Background
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
ResponseEntity<byte[]> response = controller.overlayPdfs(request);
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
@DisplayName("Should throw exception for invalid overlay mode")
void testInvalidOverlayMode() throws Exception {
byte[] baseBytes = createPdf(1);
byte[] overlayBytes = createPdf(1);
MockMultipartFile baseFile =
new MockMultipartFile(
"fileInput", "base.pdf", MediaType.APPLICATION_PDF_VALUE, baseBytes);
MockMultipartFile overlayFile =
new MockMultipartFile(
"overlayFile",
"overlay.pdf",
MediaType.APPLICATION_PDF_VALUE,
overlayBytes);
OverlayPdfsRequest request = new OverlayPdfsRequest();
request.setFileInput(baseFile);
request.setOverlayFiles(new MultipartFile[] {overlayFile});
request.setOverlayMode("InvalidMode");
request.setOverlayPosition(0);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
assertThrows(IllegalArgumentException.class, () -> controller.overlayPdfs(request));
}
@Test
@DisplayName("Should throw exception for mismatched counts in FixedRepeatOverlay")
void testFixedRepeatOverlay_MismatchedCounts() throws Exception {
byte[] baseBytes = createPdf(2);
byte[] overlay1Bytes = createPdf(1);
byte[] overlay2Bytes = createPdf(1);
MockMultipartFile baseFile =
new MockMultipartFile(
"fileInput", "base.pdf", MediaType.APPLICATION_PDF_VALUE, baseBytes);
MockMultipartFile overlay1 =
new MockMultipartFile(
"overlay1", "o1.pdf", MediaType.APPLICATION_PDF_VALUE, overlay1Bytes);
MockMultipartFile overlay2 =
new MockMultipartFile(
"overlay2", "o2.pdf", MediaType.APPLICATION_PDF_VALUE, overlay2Bytes);
OverlayPdfsRequest request = new OverlayPdfsRequest();
request.setFileInput(baseFile);
request.setOverlayFiles(new MultipartFile[] {overlay1, overlay2});
request.setOverlayMode("FixedRepeatOverlay");
request.setOverlayPosition(0);
request.setCounts(new int[] {1}); // Mismatched: 2 files but 1 count
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
assertThrows(IllegalArgumentException.class, () -> controller.overlayPdfs(request));
}
@Test
@DisplayName("Should handle single page base with multiple overlay files")
void testSinglePageBaseMultipleOverlays() throws Exception {
byte[] baseBytes = createPdf(1);
byte[] overlay1Bytes = createPdf(1);
byte[] overlay2Bytes = createPdf(1);
MockMultipartFile baseFile =
new MockMultipartFile(
"fileInput", "base.pdf", MediaType.APPLICATION_PDF_VALUE, baseBytes);
MockMultipartFile overlay1 =
new MockMultipartFile(
"overlay1", "o1.pdf", MediaType.APPLICATION_PDF_VALUE, overlay1Bytes);
MockMultipartFile overlay2 =
new MockMultipartFile(
"overlay2", "o2.pdf", MediaType.APPLICATION_PDF_VALUE, overlay2Bytes);
OverlayPdfsRequest request = new OverlayPdfsRequest();
request.setFileInput(baseFile);
request.setOverlayFiles(new MultipartFile[] {overlay1, overlay2});
request.setOverlayMode("SequentialOverlay");
request.setOverlayPosition(0);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
ResponseEntity<byte[]> response = controller.overlayPdfs(request);
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
@DisplayName("Should handle FixedRepeatOverlay with multiple files and counts")
void testFixedRepeatOverlay_MultipleFiles() throws Exception {
byte[] baseBytes = createPdf(4);
byte[] overlay1Bytes = createPdf(1);
byte[] overlay2Bytes = createPdf(1);
MockMultipartFile baseFile =
new MockMultipartFile(
"fileInput", "base.pdf", MediaType.APPLICATION_PDF_VALUE, baseBytes);
MockMultipartFile overlay1 =
new MockMultipartFile(
"overlay1", "o1.pdf", MediaType.APPLICATION_PDF_VALUE, overlay1Bytes);
MockMultipartFile overlay2 =
new MockMultipartFile(
"overlay2", "o2.pdf", MediaType.APPLICATION_PDF_VALUE, overlay2Bytes);
OverlayPdfsRequest request = new OverlayPdfsRequest();
request.setFileInput(baseFile);
request.setOverlayFiles(new MultipartFile[] {overlay1, overlay2});
request.setOverlayMode("FixedRepeatOverlay");
request.setOverlayPosition(0);
request.setCounts(new int[] {2, 2});
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
ResponseEntity<byte[]> response = controller.overlayPdfs(request);
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
@@ -1,317 +0,0 @@
package stirling.software.SPDF.controller.api;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.SPDF.model.api.general.RearrangePagesRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@ExtendWith(MockitoExtension.class)
class RearrangePagesPDFControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@InjectMocks private RearrangePagesPDFController controller;
private MockMultipartFile createMockPdf() {
return new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, new byte[] {1, 2, 3});
}
@Test
void testDeletePages_Success() throws IOException {
MockMultipartFile file = createMockPdf();
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(file);
request.setPageNumbers("1,3");
PDDocument mockDoc = mock(PDDocument.class);
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
when(mockDoc.getNumberOfPages()).thenReturn(5);
ResponseEntity<byte[]> response = controller.deletePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
verify(mockDoc).removePage(2); // page 3 (0-indexed = 2) removed first (descending)
verify(mockDoc).removePage(0); // page 1 (0-indexed = 0)
}
@Test
void testRearrangePages_ReverseOrder() throws IOException {
MockMultipartFile file = createMockPdf();
RearrangePagesRequest request = new RearrangePagesRequest();
request.setFileInput(file);
request.setPageNumbers("");
request.setCustomMode("REVERSE_ORDER");
PDDocument mockDoc = mock(PDDocument.class);
PDDocument mockNewDoc = mock(PDDocument.class);
PDPage page0 = mock(PDPage.class);
PDPage page1 = mock(PDPage.class);
PDPage page2 = mock(PDPage.class);
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
when(mockDoc.getNumberOfPages()).thenReturn(3);
when(mockDoc.getPage(0)).thenReturn(page0);
when(mockDoc.getPage(1)).thenReturn(page1);
when(mockDoc.getPage(2)).thenReturn(page2);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
.thenReturn(mockNewDoc);
ResponseEntity<byte[]> response = controller.rearrangePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
verify(mockNewDoc).addPage(page2);
verify(mockNewDoc).addPage(page1);
verify(mockNewDoc).addPage(page0);
}
@Test
void testRearrangePages_RemoveFirst() throws IOException {
MockMultipartFile file = createMockPdf();
RearrangePagesRequest request = new RearrangePagesRequest();
request.setFileInput(file);
request.setPageNumbers("");
request.setCustomMode("REMOVE_FIRST");
PDDocument mockDoc = mock(PDDocument.class);
PDDocument mockNewDoc = mock(PDDocument.class);
PDPage page0 = mock(PDPage.class);
PDPage page1 = mock(PDPage.class);
PDPage page2 = mock(PDPage.class);
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
when(mockDoc.getNumberOfPages()).thenReturn(3);
when(mockDoc.getPage(1)).thenReturn(page1);
when(mockDoc.getPage(2)).thenReturn(page2);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
.thenReturn(mockNewDoc);
ResponseEntity<byte[]> response = controller.rearrangePages(request);
assertNotNull(response);
verify(mockNewDoc).addPage(page1);
verify(mockNewDoc).addPage(page2);
verify(mockNewDoc, never()).addPage(page0);
}
@Test
void testRearrangePages_RemoveLast() throws IOException {
MockMultipartFile file = createMockPdf();
RearrangePagesRequest request = new RearrangePagesRequest();
request.setFileInput(file);
request.setPageNumbers("");
request.setCustomMode("REMOVE_LAST");
PDDocument mockDoc = mock(PDDocument.class);
PDDocument mockNewDoc = mock(PDDocument.class);
PDPage page0 = mock(PDPage.class);
PDPage page1 = mock(PDPage.class);
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
when(mockDoc.getNumberOfPages()).thenReturn(3);
when(mockDoc.getPage(0)).thenReturn(page0);
when(mockDoc.getPage(1)).thenReturn(page1);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
.thenReturn(mockNewDoc);
ResponseEntity<byte[]> response = controller.rearrangePages(request);
assertNotNull(response);
verify(mockNewDoc).addPage(page0);
verify(mockNewDoc).addPage(page1);
}
@Test
void testRearrangePages_RemoveFirstAndLast() throws IOException {
MockMultipartFile file = createMockPdf();
RearrangePagesRequest request = new RearrangePagesRequest();
request.setFileInput(file);
request.setPageNumbers("");
request.setCustomMode("REMOVE_FIRST_AND_LAST");
PDDocument mockDoc = mock(PDDocument.class);
PDDocument mockNewDoc = mock(PDDocument.class);
PDPage page1 = mock(PDPage.class);
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
when(mockDoc.getNumberOfPages()).thenReturn(4);
when(mockDoc.getPage(1)).thenReturn(page1);
when(mockDoc.getPage(2)).thenReturn(page1);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
.thenReturn(mockNewDoc);
ResponseEntity<byte[]> response = controller.rearrangePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
}
@Test
void testRearrangePages_DuplexSort() throws IOException {
MockMultipartFile file = createMockPdf();
RearrangePagesRequest request = new RearrangePagesRequest();
request.setFileInput(file);
request.setPageNumbers("");
request.setCustomMode("DUPLEX_SORT");
PDDocument mockDoc = mock(PDDocument.class);
PDDocument mockNewDoc = mock(PDDocument.class);
PDPage page0 = mock(PDPage.class);
PDPage page1 = mock(PDPage.class);
PDPage page2 = mock(PDPage.class);
PDPage page3 = mock(PDPage.class);
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
when(mockDoc.getNumberOfPages()).thenReturn(4);
when(mockDoc.getPage(anyInt())).thenReturn(page0);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
.thenReturn(mockNewDoc);
ResponseEntity<byte[]> response = controller.rearrangePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
}
@Test
void testRearrangePages_BookletSort() throws IOException {
MockMultipartFile file = createMockPdf();
RearrangePagesRequest request = new RearrangePagesRequest();
request.setFileInput(file);
request.setPageNumbers("");
request.setCustomMode("BOOKLET_SORT");
PDDocument mockDoc = mock(PDDocument.class);
PDDocument mockNewDoc = mock(PDDocument.class);
PDPage page = mock(PDPage.class);
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
when(mockDoc.getNumberOfPages()).thenReturn(4);
when(mockDoc.getPage(anyInt())).thenReturn(page);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
.thenReturn(mockNewDoc);
ResponseEntity<byte[]> response = controller.rearrangePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
}
@Test
void testRearrangePages_OddEvenSplit() throws IOException {
MockMultipartFile file = createMockPdf();
RearrangePagesRequest request = new RearrangePagesRequest();
request.setFileInput(file);
request.setPageNumbers("");
request.setCustomMode("ODD_EVEN_SPLIT");
PDDocument mockDoc = mock(PDDocument.class);
PDDocument mockNewDoc = mock(PDDocument.class);
PDPage page = mock(PDPage.class);
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
when(mockDoc.getNumberOfPages()).thenReturn(4);
when(mockDoc.getPage(anyInt())).thenReturn(page);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
.thenReturn(mockNewDoc);
ResponseEntity<byte[]> response = controller.rearrangePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
}
@Test
void testRearrangePages_CustomPageOrder() throws IOException {
MockMultipartFile file = createMockPdf();
RearrangePagesRequest request = new RearrangePagesRequest();
request.setFileInput(file);
request.setPageNumbers("3,1,2");
request.setCustomMode("custom");
PDDocument mockDoc = mock(PDDocument.class);
PDDocument mockNewDoc = mock(PDDocument.class);
PDPage page0 = mock(PDPage.class);
PDPage page1 = mock(PDPage.class);
PDPage page2 = mock(PDPage.class);
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
when(mockDoc.getNumberOfPages()).thenReturn(3);
when(mockDoc.getPage(0)).thenReturn(page0);
when(mockDoc.getPage(1)).thenReturn(page1);
when(mockDoc.getPage(2)).thenReturn(page2);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
.thenReturn(mockNewDoc);
ResponseEntity<byte[]> response = controller.rearrangePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
}
@Test
void testRearrangePages_Duplicate() throws IOException {
MockMultipartFile file = createMockPdf();
RearrangePagesRequest request = new RearrangePagesRequest();
request.setFileInput(file);
request.setPageNumbers("3");
request.setCustomMode("DUPLICATE");
PDDocument mockDoc = mock(PDDocument.class);
PDDocument mockNewDoc = mock(PDDocument.class);
PDPage page = mock(PDPage.class);
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
when(mockDoc.getNumberOfPages()).thenReturn(2);
when(mockDoc.getPage(anyInt())).thenReturn(page);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
.thenReturn(mockNewDoc);
ResponseEntity<byte[]> response = controller.rearrangePages(request);
assertNotNull(response);
// 2 pages * 3 duplicates = 6 addPage calls
verify(mockNewDoc, times(6)).addPage(page);
}
@Test
void testRearrangePages_SideStitchBooklet() throws IOException {
MockMultipartFile file = createMockPdf();
RearrangePagesRequest request = new RearrangePagesRequest();
request.setFileInput(file);
request.setPageNumbers("");
request.setCustomMode("SIDE_STITCH_BOOKLET_SORT");
PDDocument mockDoc = mock(PDDocument.class);
PDDocument mockNewDoc = mock(PDDocument.class);
PDPage page = mock(PDPage.class);
when(pdfDocumentFactory.load(file)).thenReturn(mockDoc);
when(mockDoc.getNumberOfPages()).thenReturn(4);
when(mockDoc.getPage(anyInt())).thenReturn(page);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(mockDoc))
.thenReturn(mockNewDoc);
ResponseEntity<byte[]> response = controller.rearrangePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
}
}
@@ -1,256 +0,0 @@
package stirling.software.SPDF.controller.api;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.model.api.general.ScalePagesRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@ExtendWith(MockitoExtension.class)
class ScalePagesControllerTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@InjectMocks private ScalePagesController controller;
private byte[] createRealPdf(PDRectangle pageSize, int numPages) throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < numPages; i++) {
doc.addPage(new PDPage(pageSize));
}
Path pdfPath = tempDir.resolve("input.pdf");
doc.save(pdfPath.toFile());
return Files.readAllBytes(pdfPath);
}
}
private void setupFactory() throws IOException {
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(
inv -> {
byte[] bytes = ((MultipartFile) inv.getArgument(0)).getBytes();
return org.apache.pdfbox.Loader.loadPDF(bytes);
});
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
}
@Test
void testScalePages_A4ToA3() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("A3");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<byte[]> response = controller.scalePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
assertNotNull(response.getBody());
assertTrue(response.getBody().length > 0);
}
@Test
void testScalePages_KeepSize() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 2);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("KEEP");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<byte[]> response = controller.scalePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
}
@Test
void testScalePages_WithScaleFactor() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("A4");
request.setScaleFactor(0.5f);
setupFactory();
ResponseEntity<byte[]> response = controller.scalePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
}
@Test
void testScalePages_Letter() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("LETTER");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<byte[]> response = controller.scalePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
}
@Test
void testScalePages_Legal() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("LEGAL");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<byte[]> response = controller.scalePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
}
@Test
void testScalePages_InvalidPageSize() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("INVALID_SIZE");
request.setScaleFactor(1.0f);
setupFactory();
assertThrows(IllegalArgumentException.class, () -> controller.scalePages(request));
}
@Test
void testScalePages_MultiplePages() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 5);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("A5");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<byte[]> response = controller.scalePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
}
@Test
void testScalePages_LandscapeSize() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("A4_LANDSCAPE");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<byte[]> response = controller.scalePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
}
@Test
void testScalePages_KeepWithEmptyDoc() throws Exception {
// Create a PDF then load it, but mock factory to return empty doc for KEEP check
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("KEEP");
request.setScaleFactor(1.0f);
// Return an empty document to trigger the KEEP exception
when(pdfDocumentFactory.load(any(MultipartFile.class))).thenReturn(new PDDocument());
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
assertThrows(IllegalArgumentException.class, () -> controller.scalePages(request));
}
@Test
void testScalePages_A0Size() throws Exception {
byte[] pdfBytes = createRealPdf(PDRectangle.A4, 1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
ScalePagesRequest request = new ScalePagesRequest();
request.setFileInput(file);
request.setPageSize("A0");
request.setScaleFactor(1.0f);
setupFactory();
ResponseEntity<byte[]> response = controller.scalePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
}
}
@@ -1,222 +0,0 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
class SplitPDFControllerTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private SplitPDFController controller;
@BeforeEach
void setUp() throws IOException {
when(tempFileManager.createTempFile(anyString()))
.thenAnswer(
invocation -> {
String suffix = invocation.getArgument(0);
return Files.createTempFile(tempDir, "test", suffix).toFile();
});
}
private byte[] createPdf(int numPages) throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < numPages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
Path pdfPath = tempDir.resolve("input.pdf");
doc.save(pdfPath.toFile());
return Files.readAllBytes(pdfPath);
}
}
private void setupFactory() throws IOException {
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
}
@Test
@DisplayName("Should split 6-page PDF at page 3")
void shouldSplitAtPage3() throws Exception {
byte[] pdfBytes = createPdf(6);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(file);
request.setPageNumbers("3");
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should split all pages individually")
void shouldSplitAllPages() throws Exception {
byte[] pdfBytes = createPdf(3);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(file);
request.setPageNumbers("1,2,3");
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should handle single page PDF")
void shouldHandleSinglePage() throws Exception {
byte[] pdfBytes = createPdf(1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(file);
request.setPageNumbers("1");
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should split with range notation")
void shouldSplitWithRange() throws Exception {
byte[] pdfBytes = createPdf(10);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(file);
request.setPageNumbers("3,7");
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should split 4-page PDF into 2 documents")
void shouldSplitIntoTwoDocs() throws Exception {
byte[] pdfBytes = createPdf(4);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(file);
request.setPageNumbers("2");
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
}
@Test
@DisplayName("Should split 5-page PDF at last page boundary")
void shouldSplitAtLastPage() throws Exception {
byte[] pdfBytes = createPdf(5);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(file);
request.setPageNumbers("5");
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should handle PDF with all keyword")
void shouldHandleAllKeyword() throws Exception {
byte[] pdfBytes = createPdf(3);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(file);
request.setPageNumbers("all");
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should handle file without extension in original name")
void shouldHandleFileWithoutExtension() throws Exception {
byte[] pdfBytes = createPdf(2);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "no_extension", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(file);
request.setPageNumbers("1");
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
@@ -1,263 +0,0 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageFitDestination;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SplitPdfByChaptersControllerTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private PdfMetadataService pdfMetadataService;
@Mock private TempFileManager tempFileManager;
@InjectMocks private SplitPdfByChaptersController controller;
@BeforeEach
void setUp() throws IOException {
when(tempFileManager.createTempFile(anyString()))
.thenAnswer(
inv -> {
String suffix = inv.getArgument(0);
return Files.createTempFile(tempDir, "test", suffix).toFile();
});
}
private byte[] createPdfWithBookmarks(int numPages, String... chapterNames) throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < numPages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
PDDocumentOutline outline = new PDDocumentOutline();
doc.getDocumentCatalog().setDocumentOutline(outline);
int pagesPerChapter = Math.max(1, numPages / Math.max(1, chapterNames.length));
for (int i = 0; i < chapterNames.length; i++) {
PDOutlineItem item = new PDOutlineItem();
item.setTitle(chapterNames[i]);
int pageIndex = Math.min(i * pagesPerChapter, numPages - 1);
PDPageFitDestination dest = new PDPageFitDestination();
dest.setPage(doc.getPage(pageIndex));
item.setDestination(dest);
outline.addLast(item);
}
Path pdfPath = tempDir.resolve("bookmarks.pdf");
doc.save(pdfPath.toFile());
return Files.readAllBytes(pdfPath);
}
}
@Test
@DisplayName("Should split PDF by chapters")
void shouldSplitByChapters() throws Exception {
byte[] pdfBytes = createPdfWithBookmarks(6, "Chapter 1", "Chapter 2", "Chapter 3");
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfByChaptersRequest request = new SplitPdfByChaptersRequest();
request.setFileInput(file);
request.setBookmarkLevel(0);
request.setIncludeMetadata(false);
request.setAllowDuplicates(false);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should split PDF by chapters with duplicates allowed")
void shouldSplitByChaptersWithDuplicates() throws Exception {
byte[] pdfBytes = createPdfWithBookmarks(4, "Chapter 1", "Chapter 2");
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfByChaptersRequest request = new SplitPdfByChaptersRequest();
request.setFileInput(file);
request.setBookmarkLevel(0);
request.setIncludeMetadata(false);
request.setAllowDuplicates(true);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should throw for negative bookmark level")
void shouldThrowForNegativeBookmarkLevel() throws Exception {
byte[] pdfBytes = createPdfWithBookmarks(2, "Ch1");
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfByChaptersRequest request = new SplitPdfByChaptersRequest();
request.setFileInput(file);
request.setBookmarkLevel(-1);
request.setIncludeMetadata(false);
request.setAllowDuplicates(false);
assertThrows(IllegalArgumentException.class, () -> controller.splitPdf(request));
}
@Test
@DisplayName("Should throw for PDF without bookmarks")
void shouldThrowForPdfWithoutBookmarks() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
Path pdfPath = tempDir.resolve("no_bookmarks.pdf");
doc.save(pdfPath.toFile());
byte[] pdfBytes = Files.readAllBytes(pdfPath);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfByChaptersRequest request = new SplitPdfByChaptersRequest();
request.setFileInput(file);
request.setBookmarkLevel(0);
request.setIncludeMetadata(false);
request.setAllowDuplicates(false);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(
inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
assertThrows(IllegalArgumentException.class, () -> controller.splitPdf(request));
}
}
@Test
@DisplayName("Should split single chapter PDF")
void shouldSplitSingleChapter() throws Exception {
byte[] pdfBytes = createPdfWithBookmarks(3, "Only Chapter");
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfByChaptersRequest request = new SplitPdfByChaptersRequest();
request.setFileInput(file);
request.setBookmarkLevel(0);
request.setIncludeMetadata(false);
request.setAllowDuplicates(false);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should split with metadata included")
void shouldSplitWithMetadata() throws Exception {
byte[] pdfBytes = createPdfWithBookmarks(4, "Chapter 1", "Chapter 2");
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfByChaptersRequest request = new SplitPdfByChaptersRequest();
request.setFileInput(file);
request.setBookmarkLevel(0);
request.setIncludeMetadata(true);
request.setAllowDuplicates(false);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
when(pdfMetadataService.extractMetadataFromPdf(any(PDDocument.class)))
.thenReturn(new stirling.software.common.model.PdfMetadata());
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should handle bookmark level 0")
void shouldHandleBookmarkLevel0() throws Exception {
byte[] pdfBytes = createPdfWithBookmarks(6, "Part 1", "Part 2", "Part 3");
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfByChaptersRequest request = new SplitPdfByChaptersRequest();
request.setFileInput(file);
request.setBookmarkLevel(0);
request.setIncludeMetadata(false);
request.setAllowDuplicates(false);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should handle many chapters")
void shouldHandleManyChapters() throws Exception {
byte[] pdfBytes = createPdfWithBookmarks(10, "Ch1", "Ch2", "Ch3", "Ch4", "Ch5");
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfByChaptersRequest request = new SplitPdfByChaptersRequest();
request.setFileInput(file);
request.setBookmarkLevel(0);
request.setIncludeMetadata(false);
request.setAllowDuplicates(true);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
@@ -1,292 +0,0 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SplitPdfBySectionsControllerTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private SplitPdfBySectionsController controller;
@BeforeEach
void setUp() throws IOException {
when(tempFileManager.createTempFile(anyString()))
.thenAnswer(
inv -> {
String suffix = inv.getArgument(0);
return Files.createTempFile(tempDir, "test", suffix).toFile();
});
}
private byte[] createPdf(int numPages) throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < numPages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
Path pdfPath = tempDir.resolve("input_" + numPages + ".pdf");
doc.save(pdfPath.toFile());
return Files.readAllBytes(pdfPath);
}
}
private void setupFactory() throws IOException {
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
when(pdfDocumentFactory.createNewDocument()).thenAnswer(inv -> new PDDocument());
}
@Test
@DisplayName("Should split all pages into halves with merge")
void shouldSplitAllPagesHalvesMerged() throws Exception {
byte[] pdfBytes = createPdf(2);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySectionsRequest request = new SplitPdfBySectionsRequest();
request.setFileInput(file);
request.setHorizontalDivisions(1); // 2 columns
request.setVerticalDivisions(0); // 1 row
request.setMerge(true);
request.setPageNumbers("all");
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
@Test
@DisplayName("Should split all pages into quarters without merge")
void shouldSplitAllPagesQuartersNoMerge() throws Exception {
byte[] pdfBytes = createPdf(1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySectionsRequest request = new SplitPdfBySectionsRequest();
request.setFileInput(file);
request.setHorizontalDivisions(1); // 2 columns
request.setVerticalDivisions(1); // 2 rows
request.setMerge(false);
request.setPageNumbers("all");
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should split with SPLIT_ALL mode")
void shouldSplitAllMode() throws Exception {
byte[] pdfBytes = createPdf(2);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySectionsRequest request = new SplitPdfBySectionsRequest();
request.setFileInput(file);
request.setHorizontalDivisions(0);
request.setVerticalDivisions(1);
request.setMerge(true);
request.setSplitMode("SPLIT_ALL");
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should split with SPLIT_ALL_EXCEPT_FIRST mode")
void shouldSplitExceptFirst() throws Exception {
byte[] pdfBytes = createPdf(3);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySectionsRequest request = new SplitPdfBySectionsRequest();
request.setFileInput(file);
request.setHorizontalDivisions(1);
request.setVerticalDivisions(0);
request.setMerge(true);
request.setSplitMode("SPLIT_ALL_EXCEPT_FIRST");
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should split with SPLIT_ALL_EXCEPT_LAST mode")
void shouldSplitExceptLast() throws Exception {
byte[] pdfBytes = createPdf(3);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySectionsRequest request = new SplitPdfBySectionsRequest();
request.setFileInput(file);
request.setHorizontalDivisions(1);
request.setVerticalDivisions(0);
request.setMerge(true);
request.setSplitMode("SPLIT_ALL_EXCEPT_LAST");
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should split with SPLIT_ALL_EXCEPT_FIRST_AND_LAST mode")
void shouldSplitExceptFirstAndLast() throws Exception {
byte[] pdfBytes = createPdf(4);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySectionsRequest request = new SplitPdfBySectionsRequest();
request.setFileInput(file);
request.setHorizontalDivisions(1);
request.setVerticalDivisions(0);
request.setMerge(true);
request.setSplitMode("SPLIT_ALL_EXCEPT_FIRST_AND_LAST");
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should split custom pages without merge")
void shouldSplitCustomPagesNoMerge() throws Exception {
byte[] pdfBytes = createPdf(3);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySectionsRequest request = new SplitPdfBySectionsRequest();
request.setFileInput(file);
request.setHorizontalDivisions(0);
request.setVerticalDivisions(1);
request.setMerge(false);
request.setSplitMode("CUSTOM");
request.setPageNumbers("1,3");
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should throw for CUSTOM mode with no page numbers")
void shouldThrowForCustomModeNoPages() throws Exception {
byte[] pdfBytes = createPdf(2);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySectionsRequest request = new SplitPdfBySectionsRequest();
request.setFileInput(file);
request.setHorizontalDivisions(1);
request.setVerticalDivisions(0);
request.setMerge(false);
request.setSplitMode("CUSTOM");
request.setPageNumbers("");
setupFactory();
assertThrows(Exception.class, () -> controller.splitPdf(request));
}
@Test
@DisplayName("Should handle single page PDF with merge")
void shouldHandleSinglePageMerge() throws Exception {
byte[] pdfBytes = createPdf(1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySectionsRequest request = new SplitPdfBySectionsRequest();
request.setFileInput(file);
request.setHorizontalDivisions(2); // 3 columns
request.setVerticalDivisions(2); // 3 rows = 9 sections
request.setMerge(true);
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Should split into thirds vertically")
void shouldSplitThirdsVertically() throws Exception {
byte[] pdfBytes = createPdf(1);
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySectionsRequest request = new SplitPdfBySectionsRequest();
request.setFileInput(file);
request.setHorizontalDivisions(0); // 1 column
request.setVerticalDivisions(2); // 3 rows
request.setMerge(true);
setupFactory();
var response = controller.splitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
@@ -1,310 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
import stirling.software.common.util.EmlToPdf;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@ExtendWith(MockitoExtension.class)
class ConvertEmlToPDFTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private TempFileManager tempFileManager;
@Mock private CustomHtmlSanitizer customHtmlSanitizer;
@InjectMocks private ConvertEmlToPDF controller;
@Test
void convertEmlToPdf_emptyFileReturnsBadRequest() {
MockMultipartFile emptyFile =
new MockMultipartFile("fileInput", "test.eml", "message/rfc822", new byte[0]);
EmlToPdfRequest request = new EmlToPdfRequest();
request.setFileInput(emptyFile);
ResponseEntity<byte[]> response = controller.convertEmlToPdf(request);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertTrue(
new String(response.getBody(), StandardCharsets.UTF_8)
.contains("No file provided"));
}
@Test
void convertEmlToPdf_nullFilenameReturnsBadRequest() {
MockMultipartFile file =
new MockMultipartFile("fileInput", null, "message/rfc822", "content".getBytes());
EmlToPdfRequest request = new EmlToPdfRequest();
request.setFileInput(file);
ResponseEntity<byte[]> response = controller.convertEmlToPdf(request);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertTrue(
new String(response.getBody(), StandardCharsets.UTF_8).contains("valid filename"));
}
@Test
void convertEmlToPdf_emptyFilenameReturnsBadRequest() {
MockMultipartFile file =
new MockMultipartFile("fileInput", " ", "message/rfc822", "content".getBytes());
EmlToPdfRequest request = new EmlToPdfRequest();
request.setFileInput(file);
ResponseEntity<byte[]> response = controller.convertEmlToPdf(request);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
}
@Test
void convertEmlToPdf_invalidFileTypeReturnsBadRequest() {
MockMultipartFile file =
new MockMultipartFile("fileInput", "test.txt", "text/plain", "content".getBytes());
EmlToPdfRequest request = new EmlToPdfRequest();
request.setFileInput(file);
ResponseEntity<byte[]> response = controller.convertEmlToPdf(request);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertTrue(
new String(response.getBody(), StandardCharsets.UTF_8)
.contains("valid EML or MSG"));
}
@Test
void convertEmlToPdf_successfulPdfConversion() throws Exception {
byte[] pdfBytes = "fake-pdf-content".getBytes();
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.eml", "message/rfc822", "email content".getBytes());
EmlToPdfRequest request = new EmlToPdfRequest();
request.setFileInput(file);
when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/usr/bin/weasyprint");
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok(pdfBytes);
try (MockedStatic<EmlToPdf> emlMock = Mockito.mockStatic(EmlToPdf.class);
MockedStatic<WebResponseUtils> wrMock =
Mockito.mockStatic(WebResponseUtils.class)) {
emlMock.when(
() ->
EmlToPdf.convertEmlToPdf(
eq("/usr/bin/weasyprint"),
eq(request),
any(byte[].class),
eq("test.eml"),
eq(pdfDocumentFactory),
eq(tempFileManager),
eq(customHtmlSanitizer)))
.thenReturn(pdfBytes);
wrMock.when(
() ->
WebResponseUtils.bytesToWebResponse(
pdfBytes, "test.eml.pdf", MediaType.APPLICATION_PDF))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = controller.convertEmlToPdf(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertArrayEquals(pdfBytes, response.getBody());
}
}
@Test
void convertEmlToPdf_downloadHtmlMode() throws Exception {
String htmlContent = "<html><body>email</body></html>";
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.eml", "message/rfc822", "email content".getBytes());
EmlToPdfRequest request = new EmlToPdfRequest();
request.setFileInput(file);
request.setDownloadHtml(true);
ResponseEntity<byte[]> expectedResponse =
ResponseEntity.ok(htmlContent.getBytes(StandardCharsets.UTF_8));
try (MockedStatic<EmlToPdf> emlMock = Mockito.mockStatic(EmlToPdf.class);
MockedStatic<WebResponseUtils> wrMock =
Mockito.mockStatic(WebResponseUtils.class)) {
emlMock.when(
() ->
EmlToPdf.convertEmlToHtml(
any(byte[].class),
eq(request),
eq(customHtmlSanitizer)))
.thenReturn(htmlContent);
wrMock.when(
() ->
WebResponseUtils.bytesToWebResponse(
htmlContent.getBytes(StandardCharsets.UTF_8),
"test.eml.html",
MediaType.TEXT_HTML))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = controller.convertEmlToPdf(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
@Test
void convertEmlToPdf_htmlConversionFailureReturnsError() throws Exception {
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.eml", "message/rfc822", "email content".getBytes());
EmlToPdfRequest request = new EmlToPdfRequest();
request.setFileInput(file);
request.setDownloadHtml(true);
try (MockedStatic<EmlToPdf> emlMock = Mockito.mockStatic(EmlToPdf.class)) {
emlMock.when(
() ->
EmlToPdf.convertEmlToHtml(
any(byte[].class),
eq(request),
eq(customHtmlSanitizer)))
.thenThrow(new IOException("Parse error"));
ResponseEntity<byte[]> response = controller.convertEmlToPdf(request);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertTrue(
new String(response.getBody(), StandardCharsets.UTF_8)
.contains("HTML conversion failed"));
}
}
@Test
void convertEmlToPdf_nullPdfOutputReturnsError() throws Exception {
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.eml", "message/rfc822", "email content".getBytes());
EmlToPdfRequest request = new EmlToPdfRequest();
request.setFileInput(file);
when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/usr/bin/weasyprint");
try (MockedStatic<EmlToPdf> emlMock = Mockito.mockStatic(EmlToPdf.class)) {
emlMock.when(
() ->
EmlToPdf.convertEmlToPdf(
any(), any(), any(), any(), any(), any(), any()))
.thenReturn(null);
ResponseEntity<byte[]> response = controller.convertEmlToPdf(request);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertTrue(
new String(response.getBody(), StandardCharsets.UTF_8)
.contains("empty output"));
}
}
@Test
void convertEmlToPdf_msgFileAccepted() throws Exception {
byte[] pdfBytes = "fake-pdf".getBytes();
MockMultipartFile file =
new MockMultipartFile(
"fileInput",
"outlook.msg",
"application/vnd.ms-outlook",
"msg content".getBytes());
EmlToPdfRequest request = new EmlToPdfRequest();
request.setFileInput(file);
when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/usr/bin/weasyprint");
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok(pdfBytes);
try (MockedStatic<EmlToPdf> emlMock = Mockito.mockStatic(EmlToPdf.class);
MockedStatic<WebResponseUtils> wrMock =
Mockito.mockStatic(WebResponseUtils.class)) {
emlMock.when(
() ->
EmlToPdf.convertEmlToPdf(
any(), any(), any(), any(), any(), any(), any()))
.thenReturn(pdfBytes);
wrMock.when(
() ->
WebResponseUtils.bytesToWebResponse(
any(byte[].class),
any(String.class),
any(MediaType.class)))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = controller.convertEmlToPdf(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
@Test
void convertEmlToPdf_interruptedExceptionReturnsError() throws Exception {
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "test.eml", "message/rfc822", "email content".getBytes());
EmlToPdfRequest request = new EmlToPdfRequest();
request.setFileInput(file);
when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/usr/bin/weasyprint");
try (MockedStatic<EmlToPdf> emlMock = Mockito.mockStatic(EmlToPdf.class)) {
emlMock.when(
() ->
EmlToPdf.convertEmlToPdf(
any(), any(), any(), any(), any(), any(), any()))
.thenThrow(new InterruptedException("interrupted"));
ResponseEntity<byte[]> response = controller.convertEmlToPdf(request);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertTrue(
new String(response.getBody(), StandardCharsets.UTF_8).contains("interrupted"));
}
}
}
@@ -1,156 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
import stirling.software.common.util.FileToPdf;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@ExtendWith(MockitoExtension.class)
class ConvertHtmlToPDFTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private TempFileManager tempFileManager;
@Mock private CustomHtmlSanitizer customHtmlSanitizer;
@InjectMocks private ConvertHtmlToPDF controller;
@Test
void htmlToPdf_nullFileInputThrows() {
HTMLToPdfRequest request = new HTMLToPdfRequest();
request.setFileInput(null);
assertThrows(Exception.class, () -> controller.HtmlToPdf(request));
}
@Test
void htmlToPdf_invalidExtensionThrows() {
MockMultipartFile file =
new MockMultipartFile("fileInput", "test.txt", "text/plain", "content".getBytes());
HTMLToPdfRequest request = new HTMLToPdfRequest();
request.setFileInput(file);
assertThrows(Exception.class, () -> controller.HtmlToPdf(request));
}
@Test
void htmlToPdf_validHtmlFile() throws Exception {
byte[] htmlContent = "<html><body>Hello</body></html>".getBytes();
byte[] pdfBytes = "pdf-content".getBytes();
byte[] processedPdf = "processed-pdf".getBytes();
MockMultipartFile file =
new MockMultipartFile("fileInput", "test.html", "text/html", htmlContent);
HTMLToPdfRequest request = new HTMLToPdfRequest();
request.setFileInput(file);
when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/usr/bin/weasyprint");
when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes))
.thenReturn(processedPdf);
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok(processedPdf);
try (MockedStatic<FileToPdf> ftpMock = Mockito.mockStatic(FileToPdf.class);
MockedStatic<GeneralUtils> guMock = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wrMock =
Mockito.mockStatic(WebResponseUtils.class)) {
ftpMock.when(
() ->
FileToPdf.convertHtmlToPdf(
eq("/usr/bin/weasyprint"),
eq(request),
any(byte[].class),
eq("test.html"),
eq(tempFileManager),
eq(customHtmlSanitizer)))
.thenReturn(pdfBytes);
guMock.when(() -> GeneralUtils.generateFilename("test.html", ".pdf"))
.thenReturn("test.pdf");
wrMock.when(() -> WebResponseUtils.bytesToWebResponse(processedPdf, "test.pdf"))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = controller.HtmlToPdf(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
@Test
void htmlToPdf_validZipFile() throws Exception {
byte[] zipContent = "zip-content".getBytes();
byte[] pdfBytes = "pdf-content".getBytes();
byte[] processedPdf = "processed-pdf".getBytes();
MockMultipartFile file =
new MockMultipartFile("fileInput", "archive.zip", "application/zip", zipContent);
HTMLToPdfRequest request = new HTMLToPdfRequest();
request.setFileInput(file);
when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/usr/bin/weasyprint");
when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes))
.thenReturn(processedPdf);
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok(processedPdf);
try (MockedStatic<FileToPdf> ftpMock = Mockito.mockStatic(FileToPdf.class);
MockedStatic<GeneralUtils> guMock = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wrMock =
Mockito.mockStatic(WebResponseUtils.class)) {
ftpMock.when(
() ->
FileToPdf.convertHtmlToPdf(
eq("/usr/bin/weasyprint"),
eq(request),
any(byte[].class),
eq("archive.zip"),
eq(tempFileManager),
eq(customHtmlSanitizer)))
.thenReturn(pdfBytes);
guMock.when(() -> GeneralUtils.generateFilename("archive.zip", ".pdf"))
.thenReturn("archive.pdf");
wrMock.when(() -> WebResponseUtils.bytesToWebResponse(processedPdf, "archive.pdf"))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = controller.HtmlToPdf(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
@Test
void htmlToPdf_nullFilenameThrows() {
MockMultipartFile file =
new MockMultipartFile("fileInput", null, "text/html", "content".getBytes());
HTMLToPdfRequest request = new HTMLToPdfRequest();
request.setFileInput(file);
assertThrows(Exception.class, () -> controller.HtmlToPdf(request));
}
}
@@ -1,168 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@ExtendWith(MockitoExtension.class)
class ConvertImgPDFControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private EndpointConfiguration endpointConfiguration;
@InjectMocks private ConvertImgPDFController controller;
@Test
void convertToPdf_singleImage() throws Exception {
byte[] imgContent = "fake-image".getBytes();
byte[] pdfBytes = "pdf-output".getBytes();
MockMultipartFile imgFile =
new MockMultipartFile("fileInput", "photo.jpg", "image/jpeg", imgContent);
ConvertToPdfRequest request = new ConvertToPdfRequest();
request.setFileInput(new MockMultipartFile[] {imgFile});
request.setFitOption("fillPage");
request.setColorType("color");
request.setAutoRotate(false);
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok(pdfBytes);
try (MockedStatic<PdfUtils> puMock = Mockito.mockStatic(PdfUtils.class);
MockedStatic<GeneralUtils> guMock = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wrMock =
Mockito.mockStatic(WebResponseUtils.class)) {
puMock.when(
() ->
PdfUtils.imageToPdf(
any(MockMultipartFile[].class),
eq("fillPage"),
eq(false),
eq("color"),
eq(pdfDocumentFactory)))
.thenReturn(pdfBytes);
guMock.when(() -> GeneralUtils.generateFilename("photo.jpg", "_converted.pdf"))
.thenReturn("photo_converted.pdf");
wrMock.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "photo_converted.pdf"))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = controller.convertToPdf(request);
assertSame(expectedResponse, response);
}
}
@Test
void convertToPdf_nullFitOptionDefaultsToFillPage() throws Exception {
byte[] imgContent = "fake-image".getBytes();
byte[] pdfBytes = "pdf-output".getBytes();
MockMultipartFile imgFile =
new MockMultipartFile("fileInput", "photo.png", "image/png", imgContent);
ConvertToPdfRequest request = new ConvertToPdfRequest();
request.setFileInput(new MockMultipartFile[] {imgFile});
request.setFitOption(null);
request.setColorType(null);
request.setAutoRotate(null);
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok(pdfBytes);
try (MockedStatic<PdfUtils> puMock = Mockito.mockStatic(PdfUtils.class);
MockedStatic<GeneralUtils> guMock = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wrMock =
Mockito.mockStatic(WebResponseUtils.class)) {
puMock.when(
() ->
PdfUtils.imageToPdf(
any(MockMultipartFile[].class),
eq("fillPage"),
eq(false),
eq("color"),
eq(pdfDocumentFactory)))
.thenReturn(pdfBytes);
guMock.when(() -> GeneralUtils.generateFilename("photo.png", "_converted.pdf"))
.thenReturn("photo_converted.pdf");
wrMock.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "photo_converted.pdf"))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = controller.convertToPdf(request);
assertSame(expectedResponse, response);
}
}
@Test
void convertToPdf_withAutoRotate() throws Exception {
byte[] imgContent = "fake-image".getBytes();
byte[] pdfBytes = "pdf-output".getBytes();
MockMultipartFile imgFile =
new MockMultipartFile("fileInput", "photo.jpg", "image/jpeg", imgContent);
ConvertToPdfRequest request = new ConvertToPdfRequest();
request.setFileInput(new MockMultipartFile[] {imgFile});
request.setFitOption("fitDocumentToImage");
request.setColorType("greyscale");
request.setAutoRotate(true);
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok(pdfBytes);
try (MockedStatic<PdfUtils> puMock = Mockito.mockStatic(PdfUtils.class);
MockedStatic<GeneralUtils> guMock = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wrMock =
Mockito.mockStatic(WebResponseUtils.class)) {
puMock.when(
() ->
PdfUtils.imageToPdf(
any(MockMultipartFile[].class),
eq("fitDocumentToImage"),
eq(true),
eq("greyscale"),
eq(pdfDocumentFactory)))
.thenReturn(pdfBytes);
guMock.when(() -> GeneralUtils.generateFilename("photo.jpg", "_converted.pdf"))
.thenReturn("photo_converted.pdf");
wrMock.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "photo_converted.pdf"))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = controller.convertToPdf(request);
assertSame(expectedResponse, response);
}
}
@Test
void controllerIsConstructed() {
assertNotNull(controller);
}
}
@@ -1,124 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
import stirling.software.common.util.FileToPdf;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@ExtendWith(MockitoExtension.class)
class ConvertMarkdownToPdfTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private TempFileManager tempFileManager;
@Mock private CustomHtmlSanitizer customHtmlSanitizer;
@InjectMocks private ConvertMarkdownToPdf controller;
@Test
void markdownToPdf_nullFileInputThrows() {
GeneralFile generalFile = new GeneralFile();
generalFile.setFileInput(null);
assertThrows(Exception.class, () -> controller.markdownToPdf(generalFile));
}
@Test
void markdownToPdf_invalidExtensionThrows() {
MockMultipartFile file =
new MockMultipartFile("fileInput", "test.txt", "text/plain", "content".getBytes());
GeneralFile generalFile = new GeneralFile();
generalFile.setFileInput(file);
assertThrows(Exception.class, () -> controller.markdownToPdf(generalFile));
}
@Test
void markdownToPdf_validMarkdownFile() throws Exception {
byte[] mdContent = "# Hello World\n\nThis is markdown.".getBytes();
byte[] pdfBytes = "pdf-content".getBytes();
byte[] processedPdf = "processed-pdf".getBytes();
MockMultipartFile file =
new MockMultipartFile("fileInput", "readme.md", "text/markdown", mdContent);
GeneralFile generalFile = new GeneralFile();
generalFile.setFileInput(file);
when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/usr/bin/weasyprint");
when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(any(byte[].class)))
.thenReturn(processedPdf);
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok(processedPdf);
try (MockedStatic<FileToPdf> ftpMock = Mockito.mockStatic(FileToPdf.class);
MockedStatic<GeneralUtils> guMock = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wrMock =
Mockito.mockStatic(WebResponseUtils.class)) {
ftpMock.when(
() ->
FileToPdf.convertHtmlToPdf(
eq("/usr/bin/weasyprint"),
isNull(),
any(byte[].class),
eq("converted.html"),
eq(tempFileManager),
eq(customHtmlSanitizer)))
.thenReturn(pdfBytes);
guMock.when(() -> GeneralUtils.generateFilename("readme.md", ".pdf"))
.thenReturn("readme.pdf");
wrMock.when(() -> WebResponseUtils.bytesToWebResponse(processedPdf, "readme.pdf"))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = controller.markdownToPdf(generalFile);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
@Test
void markdownToPdf_nullFilenameThrows() {
MockMultipartFile file =
new MockMultipartFile("fileInput", null, "text/markdown", "# Title".getBytes());
GeneralFile generalFile = new GeneralFile();
generalFile.setFileInput(file);
assertThrows(Exception.class, () -> controller.markdownToPdf(generalFile));
}
@Test
void controllerIsConstructed() {
assertNotNull(controller);
}
@Test
void tableAttributeProvider_setsClassOnTableBlock() {
TableAttributeProvider provider = new TableAttributeProvider();
assertNotNull(provider);
}
}
@@ -1,91 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.when;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
@ExtendWith(MockitoExtension.class)
class ConvertPDFToExcelControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@InjectMocks private ConvertPDFToExcelController controller;
@Test
void pdfToExcel_noTablesReturnsNoContent() throws Exception {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "data.pdf", "application/pdf", "pdf-content".getBytes());
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdfFile);
request.setPageNumbers("all");
// Create a real empty PDDocument for tabula to process
PDDocument emptyDoc = new PDDocument();
emptyDoc.addPage(new org.apache.pdfbox.pdmodel.PDPage());
when(pdfDocumentFactory.load(request)).thenReturn(emptyDoc);
try (MockedStatic<GeneralUtils> guMock = Mockito.mockStatic(GeneralUtils.class)) {
guMock.when(() -> GeneralUtils.removeExtension("data.pdf")).thenReturn("data");
guMock.when(
() ->
GeneralUtils.parsePageList(
Mockito.anyString(),
Mockito.anyInt(),
Mockito.eq(true)))
.thenReturn(List.of(1));
ResponseEntity<byte[]> response = controller.pdfToExcel(request);
// tabula may or may not find tables in an empty page
assertNotNull(response);
// Either NO_CONTENT (no tables) or OK (empty tables found)
assertTrue(
response.getStatusCode() == HttpStatus.NO_CONTENT
|| response.getStatusCode() == HttpStatus.OK);
}
}
private static void assertTrue(boolean condition) {
if (!condition) throw new AssertionError();
}
@Test
void controllerIsConstructed() {
assertNotNull(controller);
}
@Test
void requestModelSetsPageNumbers() {
PDFWithPageNums request = new PDFWithPageNums();
request.setPageNumbers("1,2,3");
assertEquals("1,2,3", request.getPageNumbers());
}
@Test
void requestModelDefaultPageNumbers() {
PDFWithPageNums request = new PDFWithPageNums();
request.setPageNumbers("all");
assertEquals("all", request.getPageNumbers());
}
}
@@ -1,40 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
class ConvertPDFToHtmlTest {
@Mock private TempFileManager tempFileManager;
@Mock private RuntimePathConfig runtimePathConfig;
@InjectMocks private ConvertPDFToHtml controller;
@Test
void controllerIsConstructed() {
assertNotNull(controller);
}
@Test
void processPdfToHTML_requestContainsFile() {
PDFFile file = new PDFFile();
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
file.setFileInput(pdfFile);
assertNotNull(file.getFileInput());
assertNotNull(file.getFileInput().getOriginalFilename());
}
}
@@ -1,134 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
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.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.converters.PdfToPresentationRequest;
import stirling.software.SPDF.model.api.converters.PdfToTextOrRTFRequest;
import stirling.software.SPDF.model.api.converters.PdfToWordRequest;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PDFToFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@ExtendWith(MockitoExtension.class)
class ConvertPDFToOfficeTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private RuntimePathConfig runtimePathConfig;
@InjectMocks private ConvertPDFToOffice controller;
private MockMultipartFile createPdfFile() {
return new MockMultipartFile(
"fileInput", "document.pdf", "application/pdf", "pdf-content".getBytes());
}
@Test
void processPdfToPresentation_delegatesToPdfToFile() throws Exception {
MockMultipartFile pdfFile = createPdfFile();
PdfToPresentationRequest request = new PdfToPresentationRequest();
request.setFileInput(pdfFile);
request.setOutputFormat("pptx");
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok("pptx-content".getBytes());
try (MockedStatic<PDFToFile> mock =
Mockito.mockStatic(PDFToFile.class, Mockito.CALLS_REAL_METHODS)) {
PDFToFile pdfToFile = Mockito.mock(PDFToFile.class);
// We can't easily mock the constructor, so test via the actual endpoint
// which creates PDFToFile internally. Instead, verify the method doesn't throw
// with proper mocking of the utility.
}
// Since PDFToFile is created internally (not injected), we verify
// by checking that the method runs without NPE and exercises the code path
assertNotNull(request.getOutputFormat());
assertEquals("pptx", request.getOutputFormat());
}
@Test
void processPdfToRTForTXT_withTxtFormat_usesStripper() throws Exception {
MockMultipartFile pdfFile = createPdfFile();
PdfToTextOrRTFRequest request = new PdfToTextOrRTFRequest();
request.setFileInput(pdfFile);
request.setOutputFormat("txt");
// Use a real PDDocument so PDFTextStripper.getText() works without NPE
PDDocument realDoc = new PDDocument();
realDoc.addPage(new org.apache.pdfbox.pdmodel.PDPage());
when(pdfDocumentFactory.load(pdfFile)).thenReturn(realDoc);
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok("text content".getBytes());
try (MockedStatic<GeneralUtils> guMock = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wrMock =
Mockito.mockStatic(WebResponseUtils.class)) {
guMock.when(() -> GeneralUtils.generateFilename("document.pdf", ".txt"))
.thenReturn("document.txt");
wrMock.when(
() ->
WebResponseUtils.bytesToWebResponse(
any(byte[].class),
eq("document.txt"),
eq(MediaType.TEXT_PLAIN)))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = controller.processPdfToRTForTXT(request);
assertSame(expectedResponse, response);
}
}
@Test
void processPdfToWord_hasOutputFormat() {
PdfToWordRequest request = new PdfToWordRequest();
request.setOutputFormat("docx");
assertEquals("docx", request.getOutputFormat());
}
@Test
void processPdfToPresentation_hasOutputFormat() {
PdfToPresentationRequest request = new PdfToPresentationRequest();
request.setOutputFormat("pptx");
assertEquals("pptx", request.getOutputFormat());
}
@Test
void processPdfToRTForTXT_rtfFormat_hasOutputFormat() {
PdfToTextOrRTFRequest request = new PdfToTextOrRTFRequest();
request.setOutputFormat("rtf");
assertEquals("rtf", request.getOutputFormat());
}
@Test
void processPdfToXML_delegatesCorrectly() {
PDFFile file = new PDFFile();
MockMultipartFile pdfFile = createPdfFile();
file.setFileInput(pdfFile);
assertNotNull(file.getFileInput());
}
}
@@ -1,209 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.service.PdfJsonConversionService;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.util.WebResponseUtils;
@ExtendWith(MockitoExtension.class)
class ConvertPdfJsonControllerTest {
@Mock private PdfJsonConversionService pdfJsonConversionService;
@InjectMocks private ConvertPdfJsonController controller;
@Test
void convertPdfToJson_nullFileInputThrows() {
PDFFile request = new PDFFile();
request.setFileInput(null);
assertThrows(Exception.class, () -> controller.convertPdfToJson(request, false));
}
@Test
void convertPdfToJson_success() throws Exception {
byte[] jsonBytes = "{\"pages\":[]}".getBytes();
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
when(pdfJsonConversionService.convertPdfToJson(pdfFile, false)).thenReturn(jsonBytes);
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok(jsonBytes);
try (MockedStatic<WebResponseUtils> wrMock = Mockito.mockStatic(WebResponseUtils.class)) {
wrMock.when(
() ->
WebResponseUtils.bytesToWebResponse(
jsonBytes, "doc.json", MediaType.APPLICATION_JSON))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = controller.convertPdfToJson(request, false);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
@Test
void convertPdfToJson_lightweightMode() throws Exception {
byte[] jsonBytes = "{\"pages\":[]}".getBytes();
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
when(pdfJsonConversionService.convertPdfToJson(pdfFile, true)).thenReturn(jsonBytes);
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok(jsonBytes);
try (MockedStatic<WebResponseUtils> wrMock = Mockito.mockStatic(WebResponseUtils.class)) {
wrMock.when(
() ->
WebResponseUtils.bytesToWebResponse(
jsonBytes, "doc.json", MediaType.APPLICATION_JSON))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = controller.convertPdfToJson(request, true);
assertEquals(HttpStatus.OK, response.getStatusCode());
verify(pdfJsonConversionService).convertPdfToJson(pdfFile, true);
}
}
@Test
void convertJsonToPdf_nullFileInputThrows() {
GeneralFile request = new GeneralFile();
request.setFileInput(null);
assertThrows(Exception.class, () -> controller.convertJsonToPdf(request));
}
@Test
void convertJsonToPdf_success() throws Exception {
byte[] pdfBytes = "pdf-content".getBytes();
MockMultipartFile jsonFile =
new MockMultipartFile(
"fileInput", "doc.json", "application/json", "{\"pages\":[]}".getBytes());
GeneralFile request = new GeneralFile();
request.setFileInput(jsonFile);
when(pdfJsonConversionService.convertJsonToPdf(jsonFile)).thenReturn(pdfBytes);
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok(pdfBytes);
try (MockedStatic<WebResponseUtils> wrMock = Mockito.mockStatic(WebResponseUtils.class)) {
wrMock.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "doc.pdf"))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = controller.convertJsonToPdf(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
@Test
void extractPdfMetadata_nullFileInputThrows() {
PDFFile request = new PDFFile();
request.setFileInput(null);
assertThrows(Exception.class, () -> controller.extractPdfMetadata(request));
}
@Test
void extractPdfMetadata_success() throws Exception {
byte[] jsonBytes = "{\"metadata\":{}}".getBytes();
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
when(pdfJsonConversionService.extractDocumentMetadata(eq(pdfFile), any(String.class)))
.thenReturn(jsonBytes);
ResponseEntity<byte[]> response = controller.extractPdfMetadata(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(MediaType.APPLICATION_JSON, response.getHeaders().getContentType());
assertNotNull(response.getHeaders().getFirst("X-Job-Id"));
}
@Test
void clearCache_success() {
String jobId = "test-job-id";
ResponseEntity<Void> response = controller.clearCache(jobId);
assertEquals(HttpStatus.OK, response.getStatusCode());
verify(pdfJsonConversionService).clearCachedDocument(jobId);
}
@Test
void extractSinglePage_success() throws Exception {
byte[] jsonBytes = "{\"content\":[]}".getBytes();
String jobId = "test-job-id";
when(pdfJsonConversionService.extractSinglePage(jobId, 1)).thenReturn(jsonBytes);
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok(jsonBytes);
try (MockedStatic<WebResponseUtils> wrMock = Mockito.mockStatic(WebResponseUtils.class)) {
wrMock.when(
() ->
WebResponseUtils.bytesToWebResponse(
jsonBytes, "page_1.json", MediaType.APPLICATION_JSON))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = controller.extractSinglePage(jobId, 1);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
@Test
void extractPageFonts_success() throws Exception {
byte[] jsonBytes = "{\"fonts\":[]}".getBytes();
String jobId = "test-job-id";
when(pdfJsonConversionService.extractPageFonts(jobId, 1)).thenReturn(jsonBytes);
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok(jsonBytes);
try (MockedStatic<WebResponseUtils> wrMock = Mockito.mockStatic(WebResponseUtils.class)) {
wrMock.when(
() ->
WebResponseUtils.bytesToWebResponse(
jsonBytes,
"page_fonts_1.json",
MediaType.APPLICATION_JSON))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = controller.extractPageFonts(jobId, 1);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
}
@@ -1,164 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.converters.PdfToVideoRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CheckProgramInstall;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
class ConvertPdfToVideoControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private ConvertPdfToVideoController controller;
@Test
void convertPdfToVideo_ffmpegNotAvailableThrows() {
PdfToVideoRequest request = new PdfToVideoRequest();
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
request.setFileInput(pdfFile);
try (MockedStatic<CheckProgramInstall> mock =
Mockito.mockStatic(CheckProgramInstall.class)) {
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(false);
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
}
}
@Test
void convertPdfToVideo_nullFileThrows() {
PdfToVideoRequest request = new PdfToVideoRequest();
request.setFileInput(null);
try (MockedStatic<CheckProgramInstall> mock =
Mockito.mockStatic(CheckProgramInstall.class)) {
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
}
}
@Test
void convertPdfToVideo_emptyFileThrows() {
PdfToVideoRequest request = new PdfToVideoRequest();
MockMultipartFile emptyFile =
new MockMultipartFile("fileInput", "doc.pdf", "application/pdf", new byte[0]);
request.setFileInput(emptyFile);
try (MockedStatic<CheckProgramInstall> mock =
Mockito.mockStatic(CheckProgramInstall.class)) {
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
}
}
@Test
void convertPdfToVideo_nonPdfContentTypeReturnsBadRequest() throws Exception {
PdfToVideoRequest request = new PdfToVideoRequest();
MockMultipartFile txtFile =
new MockMultipartFile("fileInput", "doc.txt", "text/plain", "content".getBytes());
request.setFileInput(txtFile);
try (MockedStatic<CheckProgramInstall> mock =
Mockito.mockStatic(CheckProgramInstall.class)) {
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
ResponseEntity<byte[]> response = controller.convertPdfToVideo(request);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
}
}
@Test
void convertPdfToVideo_invalidOpacityThrows() {
PdfToVideoRequest request = new PdfToVideoRequest();
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
request.setFileInput(pdfFile);
request.setOpacity(1.5f);
try (MockedStatic<CheckProgramInstall> mock =
Mockito.mockStatic(CheckProgramInstall.class)) {
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
}
}
@Test
void convertPdfToVideo_negativeOpacityThrows() {
PdfToVideoRequest request = new PdfToVideoRequest();
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
request.setFileInput(pdfFile);
request.setOpacity(-0.1f);
try (MockedStatic<CheckProgramInstall> mock =
Mockito.mockStatic(CheckProgramInstall.class)) {
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
}
}
@Test
void convertPdfToVideo_negativeSecondsPerPageThrows() {
PdfToVideoRequest request = new PdfToVideoRequest();
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
request.setFileInput(pdfFile);
request.setSecondsPerPage(-1);
try (MockedStatic<CheckProgramInstall> mock =
Mockito.mockStatic(CheckProgramInstall.class)) {
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
}
}
@Test
void convertPdfToVideo_zeroSecondsPerPageThrows() {
PdfToVideoRequest request = new PdfToVideoRequest();
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
request.setFileInput(pdfFile);
request.setSecondsPerPage(0);
try (MockedStatic<CheckProgramInstall> mock =
Mockito.mockStatic(CheckProgramInstall.class)) {
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
}
}
@Test
void controllerIsConstructed() {
assertNotNull(controller);
}
}

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