mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09e472327b |
@@ -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/
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ indent_size = 4
|
||||
max_line_length = 100
|
||||
|
||||
[*.py]
|
||||
indent_size = 4
|
||||
indent_size = 2
|
||||
|
||||
[*.gradle]
|
||||
indent_size = 4
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,8 +68,3 @@ updates:
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directory: /frontend/src-tauri/provisioner
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
pr_ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -359,7 +359,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Run Gradle Command
|
||||
run: |
|
||||
@@ -372,7 +372,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
name: AI Engine CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
engine:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: engine
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: make install
|
||||
|
||||
- name: Run fixers
|
||||
# Ignore errors here because we're going to add comments for them in the following steps before actually failing
|
||||
run: make fix || true
|
||||
|
||||
- name: Check for fixer changes
|
||||
id: fixer_changes
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Post fixer suggestions
|
||||
if: steps.fixer_changes.outputs.changed == 'true' && github.event_name == 'pull_request'
|
||||
uses: reviewdog/action-suggester@v1
|
||||
continue-on-error: true
|
||||
with:
|
||||
tool_name: engine-make-fix
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
filter_mode: file
|
||||
fail_level: any
|
||||
level: info
|
||||
|
||||
- name: Comment on fixer suggestions
|
||||
if: steps.fixer_changes.outputs.changed == 'true' && github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: "The Python code in your PR has formatting/linting issues. Consider running `make fix` locally or setting up your editor's Ruff integration to auto-format and lint your files as you go, or commit the suggested changes on this PR.",
|
||||
});
|
||||
|
||||
- name: Verify fixer changes are committed
|
||||
if: steps.fixer_changes.outputs.changed == 'true'
|
||||
run: |
|
||||
if ! git diff --exit-code; then
|
||||
echo "Fixes are out of date."
|
||||
echo "Apply the reviewdog suggestions or run 'make fix' from engine/ and commit the updated files."
|
||||
git --no-pager diff --stat
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run linting
|
||||
run: make lint
|
||||
|
||||
- name: Run type checking
|
||||
run: make typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: make test
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
- name: AI PR Title Analysis
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
id: ai-title-analysis
|
||||
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
|
||||
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt-file: ".github/config/system-prompt.txt"
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: srvaroa/labeler@bf262763a8a8e191f5847873aecc0f29df84f957 # v1.14.0
|
||||
- uses: srvaroa/labeler@0a20eccb8c94a1ee0bed5f16859aece1c45c3e55 # v1.13.0
|
||||
with:
|
||||
config_path: .github/labeler-config-srvaroa.yml
|
||||
use_local_config: false
|
||||
|
||||
+31
-104
@@ -30,17 +30,16 @@ 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
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: changes
|
||||
with:
|
||||
filters: .github/config/.files.yaml
|
||||
@@ -57,7 +56,7 @@ jobs:
|
||||
spring-security: [true, false]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -70,7 +69,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -81,7 +80,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
cache-disabled: true
|
||||
|
||||
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
||||
@@ -112,7 +111,7 @@ jobs:
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
|
||||
path: |
|
||||
@@ -141,21 +140,21 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -166,7 +165,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
cache-disabled: true
|
||||
|
||||
- name: Generate OpenAPI documentation
|
||||
@@ -178,7 +177,7 @@ jobs:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Upload OpenAPI Documentation
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: openapi-docs
|
||||
path: ./SwaggerDoc.json
|
||||
@@ -189,13 +188,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
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
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -203,7 +202,7 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
run: cd frontend && npm ci
|
||||
- name: Type-check frontend
|
||||
run: cd frontend && npm run prep && npm run typecheck:all
|
||||
run: cd frontend && npm run prebuild && npm run typecheck:all
|
||||
- name: Lint frontend
|
||||
run: cd frontend && npm run lint
|
||||
- name: Build frontend
|
||||
@@ -211,52 +210,19 @@ jobs:
|
||||
- name: Run frontend tests
|
||||
run: cd frontend && npm run test -- --run
|
||||
- name: Upload frontend build artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: frontend-build
|
||||
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]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -270,7 +236,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -281,7 +247,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
cache-disabled: true
|
||||
|
||||
- name: check the licenses for compatibility
|
||||
@@ -298,7 +264,7 @@ jobs:
|
||||
|
||||
- name: FAILED - check the licenses for compatibility
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
@@ -329,7 +295,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -343,7 +309,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -354,7 +320,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
cache-disabled: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -362,7 +328,7 @@ jobs:
|
||||
|
||||
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
|
||||
- name: Expose GitHub runtime for Buildx cache
|
||||
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
|
||||
uses: crazy-max/ghaction-github-runtime@v3
|
||||
|
||||
- name: Install Docker Compose
|
||||
run: |
|
||||
@@ -370,7 +336,7 @@ jobs:
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
@@ -391,26 +357,16 @@ 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()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: cucumber-report
|
||||
path: testing/cucumber/report.html
|
||||
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
|
||||
@@ -439,24 +395,13 @@ jobs:
|
||||
cache-scope: stirling-pdf-fat
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- 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@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -482,7 +427,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
cache-disabled: true
|
||||
|
||||
- name: Build application
|
||||
@@ -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,15 +455,13 @@ 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
|
||||
|
||||
- name: Upload Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: reports-docker-${{ matrix.artifact-suffix }}
|
||||
path: |
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -196,7 +196,7 @@ jobs:
|
||||
core.exportVariable("REFERENCE_FILE", referenceFilePath);
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: changes
|
||||
with:
|
||||
filters: .github/config/.files.yaml
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -312,7 +312,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -339,7 +339,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Check licenses and generate report
|
||||
id: license-check
|
||||
@@ -369,7 +369,7 @@ jobs:
|
||||
|
||||
- name: Upload artifact on license issues
|
||||
if: env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: backend-dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
file_suffix: "-server"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -134,11 +134,11 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
|
||||
|
||||
- name: Upload JAR artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: jar${{ matrix.variant.file_suffix }}
|
||||
path: ./jar-dist/*.jar
|
||||
@@ -179,12 +179,9 @@ jobs:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
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
|
||||
@@ -196,7 +193,7 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -217,7 +214,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Build Java backend with JLink
|
||||
working-directory: ./
|
||||
@@ -302,7 +299,7 @@ jobs:
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
|
||||
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
|
||||
uses: digicert/ssm-code-signing@af7f4b07694120e514fe5b19f0598af3c20256e3 # v1.2.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
@@ -422,9 +419,8 @@ jobs:
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
|
||||
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
# Only enable Windows signing in Tauri when on release or V2-master
|
||||
SIGN: ${{ (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
@@ -545,7 +541,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
@@ -559,30 +555,30 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download all Tauri artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
pattern: Stirling-PDF-*
|
||||
path: ./artifacts/tauri
|
||||
|
||||
- name: Download JAR artifact (default)
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: jar
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Download JAR artifact (with login)
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: jar-with-login
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Download JAR artifact (server only)
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: jar-server
|
||||
path: ./artifacts/jars
|
||||
@@ -591,7 +587,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
|
||||
|
||||
@@ -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
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip" # caching pip dependencies
|
||||
@@ -59,16 +59,16 @@ jobs:
|
||||
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
|
||||
continue-on-error: true
|
||||
|
||||
- name: Set up JDK 25
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
java-version: 21
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
|
||||
@@ -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
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
@@ -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
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for latest
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -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
|
||||
@@ -153,7 +151,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for latest-fat
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
@@ -197,7 +195,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for ultra-lite
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
@@ -75,6 +75,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v3.29.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Generate Swagger documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
- macos
|
||||
- linux
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, V2-tauri-windows]
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
paths:
|
||||
- "frontend/src-tauri/**"
|
||||
@@ -37,48 +37,33 @@ jobs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Determine build matrix
|
||||
id: set-matrix
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
run: |
|
||||
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}'
|
||||
MACOS_ARM='{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"}'
|
||||
MACOS_INTEL='{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}'
|
||||
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}'
|
||||
|
||||
# Resolve requested platform (non-dispatch events always build all)
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
PLATFORM="${{ github.event.inputs.platform }}"
|
||||
case "${{ github.event.inputs.platform }}" in
|
||||
"windows")
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
else
|
||||
PLATFORM="all"
|
||||
# For PR/push events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Build candidate list
|
||||
case "$PLATFORM" in
|
||||
windows) ENTRIES=("$WINDOWS") ;;
|
||||
macos) ENTRIES=("$MACOS_ARM" "$MACOS_INTEL") ;;
|
||||
linux) ENTRIES=("$LINUX") ;;
|
||||
*) ENTRIES=("$WINDOWS" "$MACOS_ARM" "$MACOS_INTEL" "$LINUX") ;;
|
||||
esac
|
||||
|
||||
# Drop macOS entries when Apple certificate secret is unavailable
|
||||
if [ -z "$APPLE_CERTIFICATE" ]; then
|
||||
echo "⚠️ APPLE_CERTIFICATE secret not available - skipping macOS builds"
|
||||
FILTERED=()
|
||||
for entry in "${ENTRIES[@]}"; do
|
||||
[[ "$entry" != *'"macos'* ]] && FILTERED+=("$entry")
|
||||
done
|
||||
ENTRIES=("${FILTERED[@]}")
|
||||
fi
|
||||
|
||||
JOINED=$(IFS=','; echo "${ENTRIES[*]}")
|
||||
echo "matrix={\"include\":[$JOINED]}" >> $GITHUB_OUTPUT
|
||||
|
||||
build:
|
||||
needs: determine-matrix
|
||||
strategy:
|
||||
@@ -88,10 +73,9 @@ jobs:
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -105,7 +89,7 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -117,16 +101,16 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Build Java backend with JLink
|
||||
working-directory: ./
|
||||
@@ -212,7 +196,7 @@ jobs:
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
|
||||
uses: digicert/ssm-code-signing@af7f4b07694120e514fe5b19f0598af3c20256e3 # v1.2.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
@@ -287,7 +271,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -308,7 +292,7 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
@@ -343,9 +327,9 @@ jobs:
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
|
||||
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL }}
|
||||
# Only enable Windows signing in Tauri when on main
|
||||
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
@@ -606,7 +590,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
@@ -667,7 +651,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -759,7 +743,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -25,23 +25,23 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
@@ -131,14 +131,14 @@ jobs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: changes
|
||||
with:
|
||||
filters: ".github/config/.files.yaml"
|
||||
@@ -150,14 +150,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
+1
-9
@@ -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,7 @@ __pycache__/
|
||||
*.pyo
|
||||
|
||||
# Virtual environments
|
||||
.env*
|
||||
.venv*
|
||||
env*/
|
||||
venv*/
|
||||
@@ -201,9 +196,6 @@ out/
|
||||
*.jks
|
||||
*.asc
|
||||
|
||||
# Allow test fixture certificates (synthetic, no real credentials)
|
||||
!frontend/src/core/tests/test-fixtures/certs/**
|
||||
|
||||
# SSH Keys
|
||||
*.pub
|
||||
*.priv
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to AI Agents when working with code in this repository.
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Build and Test
|
||||
- **Build project**: `./gradlew clean build`
|
||||
- **Run locally**: `./gradlew bootRun`
|
||||
- **Full test suite**: `./test.sh` (builds all Docker variants and runs comprehensive tests)
|
||||
- **Code formatting**: `./gradlew spotlessApply` (runs automatically before compilation)
|
||||
|
||||
### Docker Development
|
||||
- **Build ultra-lite**: `docker build -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .`
|
||||
- **Build standard**: `docker build -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .`
|
||||
- **Build fat version**: `docker build -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .`
|
||||
- **Example compose files**: Located in `exampleYmlFiles/` directory
|
||||
|
||||
### Security Mode Development
|
||||
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.
|
||||
|
||||
- Follow the engine-specific guidance in [engine/AGENTS.md](engine/AGENTS.md) for Python architecture, code style, and AI usage.
|
||||
- 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`.
|
||||
|
||||
### Frontend Development
|
||||
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
|
||||
- **Tech Stack**: Vite + React + TypeScript + Mantine UI + TailwindCSS
|
||||
- **Proxy Configuration**: Vite proxies `/api/*` calls to backend (localhost:8080)
|
||||
- **Build Process**: DO NOT run build scripts manually - builds are handled by CI/CD pipelines
|
||||
- **Package Installation**: DO NOT run npm install commands - package management handled separately
|
||||
- **Deployment Options**:
|
||||
- **Desktop App**: `npm run tauri-build` (native desktop application)
|
||||
- **Web Server**: `npm run build` then serve dist/ folder
|
||||
- **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
|
||||
- See `frontend/README.md#environment-variables` for full documentation
|
||||
|
||||
#### Import Paths - CRITICAL
|
||||
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
|
||||
|
||||
For a broader explanation of the frontend layering and override architecture, see [frontend/DeveloperGuide.md](frontend/DeveloperGuide.md).
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use @app/* for all imports
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { useFileContext } from "@app/contexts/FileContext";
|
||||
import { FileContext } from "@app/contexts/FileContext";
|
||||
|
||||
// ❌ WRONG - Do not use @core/* or @proprietary/* in normal code
|
||||
import { AppLayout } from "@core/components/AppLayout";
|
||||
import { useFileContext } from "@proprietary/contexts/FileContext";
|
||||
```
|
||||
|
||||
**Only use explicit aliases when:**
|
||||
- Building layer-specific override that wraps a lower layer's component
|
||||
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
|
||||
|
||||
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
|
||||
|
||||
#### Component Override Pattern (Stub/Shadow)
|
||||
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
|
||||
|
||||
**How it works:**
|
||||
1. Core defines stub component (returns null or no-op)
|
||||
2. Desktop/proprietary overrides with same path/name
|
||||
3. Core imports via `@app/*` - higher layer "shadows" core in those builds
|
||||
4. No `@ts-ignore`, no `isTauri()` checks, no runtime conditionals!
|
||||
|
||||
**Example - Desktop-specific footer:**
|
||||
|
||||
```typescript
|
||||
// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
|
||||
return null; // Stub - does nothing in web builds
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
|
||||
import { Box } from '@mantine/core';
|
||||
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
||||
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
|
||||
return (
|
||||
<Box className={className}>
|
||||
<BackendHealthIndicator />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
|
||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||
|
||||
export function RightRail() {
|
||||
return (
|
||||
<div>
|
||||
{/* In web builds: renders nothing (stub returns null) */}
|
||||
{/* In desktop builds: renders BackendHealthIndicator */}
|
||||
<RightRailFooterExtensions className="right-rail-footer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Build resolution:**
|
||||
- **Core build**: `@app/*` → `core/*` → Gets stub (returns null)
|
||||
- **Desktop build**: `@app/*` → `desktop/*` → Gets real implementation (shadows core)
|
||||
|
||||
**Benefits:**
|
||||
- No runtime checks or feature flags
|
||||
- Type-safe across all builds
|
||||
- Clean, readable code
|
||||
- Build-time optimization (dead code elimination)
|
||||
|
||||
#### Multi-Tool Workflow Architecture
|
||||
Frontend designed for **stateful document processing**:
|
||||
- Users upload PDFs once, then chain tools (split → merge → compress → view)
|
||||
- File state and processing results persist across tool switches
|
||||
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
|
||||
|
||||
#### FileContext - Central State Management
|
||||
**Location**: `frontend/src/core/contexts/FileContext.tsx`
|
||||
- **Active files**: Currently loaded PDFs and their variants
|
||||
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
|
||||
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
|
||||
- **IndexedDB persistence**: File storage with thumbnail caching
|
||||
- **Preview system**: Tools can preview results (e.g., Split → Viewer → back to Split) without context pollution
|
||||
|
||||
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
|
||||
|
||||
#### Processing Services
|
||||
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
|
||||
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
|
||||
- **fileStorage**: IndexedDB with LRU cache management
|
||||
|
||||
#### Memory Management Strategy
|
||||
**Why manual cleanup exists**: Large PDFs (up to 100GB+) through multiple tools accumulate:
|
||||
- PDF.js documents that need explicit .destroy() calls
|
||||
- Blob URLs from tool outputs that need revocation
|
||||
- Web Workers that need termination
|
||||
Without cleanup: browser crashes with memory leaks.
|
||||
|
||||
#### Tool Development
|
||||
|
||||
**Architecture**: Modular hook-based system with clear separation of concerns:
|
||||
|
||||
- **useToolOperation** (`frontend/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||
- Coordinates all tool operations with consistent interface
|
||||
- Integrates with FileContext for operation tracking
|
||||
- Handles validation, error handling, and UI state management
|
||||
|
||||
- **Supporting Hooks**:
|
||||
- **useToolState**: UI state management (loading, progress, error, files)
|
||||
- **useToolApiCalls**: HTTP requests and file processing
|
||||
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
|
||||
|
||||
- **Utilities**:
|
||||
- **toolErrorHandler**: Standardized error extraction and i18n support
|
||||
- **toolResponseProcessor**: API response handling (single/zip/custom)
|
||||
- **toolOperationTracker**: FileContext integration utilities
|
||||
|
||||
**Three Tool Patterns**:
|
||||
|
||||
**Pattern 1: Single-File Tools** (Individual processing)
|
||||
- Backend processes one file per API call
|
||||
- Set `multiFileEndpoint: false`
|
||||
- Examples: Compress, Rotate
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'compress',
|
||||
endpoint: '/api/v1/misc/compress-pdf',
|
||||
buildFormData: (params, file: File) => { /* single file */ },
|
||||
multiFileEndpoint: false,
|
||||
});
|
||||
```
|
||||
|
||||
**Pattern 2: Multi-File Tools** (Batch processing)
|
||||
- Backend accepts `MultipartFile[]` arrays in single API call
|
||||
- Set `multiFileEndpoint: true`
|
||||
- Examples: Split, Merge, Overlay
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'split',
|
||||
endpoint: '/api/v1/general/split-pages',
|
||||
buildFormData: (params, files: File[]) => { /* all files */ },
|
||||
multiFileEndpoint: true,
|
||||
filePrefix: 'split_',
|
||||
});
|
||||
```
|
||||
|
||||
**Pattern 3: Complex Tools** (Custom processing)
|
||||
- Tools with complex routing logic or non-standard processing
|
||||
- Provide `customProcessor` for full control
|
||||
- Examples: Convert, OCR
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'convert',
|
||||
customProcessor: async (params, files) => { /* custom logic */ },
|
||||
});
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
|
||||
- **Consistent**: All tools follow same pattern and interface
|
||||
- **Maintainable**: Single responsibility hooks, easy to test and modify
|
||||
- **i18n Ready**: Built-in internationalization support
|
||||
- **Type Safe**: Full TypeScript support with generic interfaces
|
||||
- **Memory Safe**: Automatic resource cleanup and blob URL management
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Project Structure
|
||||
- **Backend**: Spring Boot application
|
||||
- **Frontend**: React-based SPA in `/frontend` directory
|
||||
- **File Storage**: IndexedDB for client-side file persistence and thumbnails
|
||||
- **Internationalization**: JSON-based translations (converted from backend .properties)
|
||||
- **PDF Processing**: PDFBox for core PDF operations, LibreOffice for conversions, PDF.js for client-side rendering
|
||||
- **Security**: Spring Security with optional authentication (controlled by `DOCKER_ENABLE_SECURITY`)
|
||||
- **Configuration**: YAML-based configuration with environment variable overrides
|
||||
|
||||
### Controller Architecture
|
||||
- **API Controllers** (`src/main/java/.../controller/api/`): REST endpoints for PDF operations
|
||||
- Organized by function: converters, security, misc, pipeline
|
||||
- Follow pattern: `@RestController` + `@RequestMapping("/api/v1/...")`
|
||||
|
||||
### Key Components
|
||||
- **SPDFApplication.java**: Main application class with desktop UI and browser launching logic
|
||||
- **ConfigInitializer**: Handles runtime configuration and settings files
|
||||
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
|
||||
- **Security Layer**: Authentication, authorization, and user management (when enabled)
|
||||
|
||||
### Frontend Directory Structure
|
||||
The frontend is organized with a clear separation of concerns:
|
||||
|
||||
- **`frontend/src/core/`**: Main application code (shared, production-ready components)
|
||||
- **`core/components/`**: React components organized by feature
|
||||
- `core/components/tools/`: Individual PDF tool implementations
|
||||
- `core/components/viewer/`: PDF viewer components
|
||||
- `core/components/pageEditor/`: Page manipulation UI
|
||||
- `core/components/tooltips/`: Help tooltips for tools
|
||||
- `core/components/shared/`: Reusable UI components
|
||||
- **`core/contexts/`**: React Context providers
|
||||
- `FileContext.tsx`: Central file state management
|
||||
- `file/`: File reducer and selectors
|
||||
- `toolWorkflow/`: Tool workflow state
|
||||
- **`core/hooks/`**: Custom React hooks
|
||||
- `hooks/tools/`: Tool-specific operation hooks (one directory per tool)
|
||||
- `hooks/tools/shared/`: Shared hook utilities (useToolOperation, etc.)
|
||||
- **`core/constants/`**: Application constants and configuration
|
||||
- **`core/data/`**: Static data (tool taxonomy, etc.)
|
||||
- **`core/services/`**: Business logic services (PDF processing, storage, etc.)
|
||||
|
||||
- **`frontend/src/desktop/`**: Desktop-specific (Tauri) code
|
||||
- **`frontend/src/proprietary/`**: Proprietary/licensed features
|
||||
- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code
|
||||
- **`frontend/public/`**: Static assets served directly
|
||||
- `public/locales/`: Translation JSON files
|
||||
|
||||
### Component Architecture
|
||||
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
|
||||
- **Internationalization**:
|
||||
- Backend: `messages_*.properties` files
|
||||
- Frontend: JSON files in `frontend/public/locales/` (converted from .properties)
|
||||
- Conversion Script: `scripts/convert_properties_to_json.py`
|
||||
|
||||
### Configuration Modes
|
||||
- **Ultra-lite**: Basic PDF operations only
|
||||
- **Standard**: Full feature set
|
||||
- **Fat**: Pre-downloaded dependencies for air-gapped environments
|
||||
- **Security Mode**: Adds authentication, user management, and enterprise features
|
||||
|
||||
### Testing Strategy
|
||||
- **Integration Tests**: Cucumber tests in `testing/cucumber/`
|
||||
- **Docker Testing**: `test.sh` validates all Docker variants
|
||||
- **Manual Testing**: No unit tests currently - relies on UI and API testing
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Local Development**:
|
||||
- Backend: `./gradlew bootRun` (runs on localhost:8080)
|
||||
- Frontend: `cd frontend && npm run dev` (runs on localhost:5173, proxies to backend)
|
||||
2. **Docker Testing**: Use `./test.sh` before submitting PRs
|
||||
3. **Code Style**: Spotless enforces Google Java Format automatically
|
||||
4. **Translations**:
|
||||
- Backend: Use helper scripts in `/scripts` for multi-language updates
|
||||
- Frontend: Update JSON files in `frontend/public/locales/` or use conversion script
|
||||
5. **Documentation**: API docs auto-generated and available at `/swagger-ui/index.html`
|
||||
|
||||
## Frontend Architecture Status
|
||||
|
||||
- **Core Status**: React SPA architecture complete with multi-tool workflow support
|
||||
- **State Management**: FileContext handles all file operations and tool navigation
|
||||
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
|
||||
- **Tool Integration**: Modular hook architecture with `useToolOperation` orchestrator
|
||||
- Individual hooks: `useToolState`, `useToolApiCalls`, `useToolResources`
|
||||
- Utilities: `toolErrorHandler`, `toolResponseProcessor`, `toolOperationTracker`
|
||||
- Pattern: Each tool creates focused operation hook, UI consumes state/actions
|
||||
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
||||
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
||||
|
||||
## Translation Rules
|
||||
|
||||
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
|
||||
- Translation files are located in `frontend/public/locales/`
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Java Version**: Minimum JDK 21, supports and recommends JDK 25
|
||||
- **Lombok**: Used extensively - ensure IDE plugin is installed
|
||||
- **File Persistence**:
|
||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
||||
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
|
||||
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
|
||||
- **Import Paths**: ALWAYS use `@app/*` for imports - never use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer
|
||||
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
|
||||
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
|
||||
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
|
||||
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
|
||||
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
|
||||
- **Adding Tools**: See `ADDING_TOOLS.md` for complete guide to creating new PDF tools
|
||||
|
||||
## Communication Style
|
||||
- Be direct and to the point
|
||||
- No apologies or conversational filler
|
||||
- Answer questions directly without preamble
|
||||
- Explain reasoning concisely when asked
|
||||
- Avoid unnecessary elaboration
|
||||
|
||||
## Decision Making
|
||||
- Ask clarifying questions before making assumptions
|
||||
- Stop and ask when uncertain about project-specific details
|
||||
- Confirm approach before making structural changes
|
||||
- Request guidance on preferences (cross-platform vs specific tools, etc.)
|
||||
- Verify understanding of requirements before proceeding
|
||||
@@ -0,0 +1,337 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Build and Test
|
||||
- **Build project**: `./gradlew clean build`
|
||||
- **Run locally**: `./gradlew bootRun`
|
||||
- **Full test suite**: `./test.sh` (builds all Docker variants and runs comprehensive tests)
|
||||
- **Code formatting**: `./gradlew spotlessApply` (runs automatically before compilation)
|
||||
|
||||
### Docker Development
|
||||
- **Build ultra-lite**: `docker build -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .`
|
||||
- **Build standard**: `docker build -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .`
|
||||
- **Build fat version**: `docker build -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .`
|
||||
- **Example compose files**: Located in `exampleYmlFiles/` directory
|
||||
|
||||
### Security Mode Development
|
||||
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
|
||||
|
||||
### Frontend Development
|
||||
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
|
||||
- **Tech Stack**: Vite + React + TypeScript + Mantine UI + TailwindCSS
|
||||
- **Proxy Configuration**: Vite proxies `/api/*` calls to backend (localhost:8080)
|
||||
- **Build Process**: DO NOT run build scripts manually - builds are handled by CI/CD pipelines
|
||||
- **Package Installation**: DO NOT run npm install commands - package management handled separately
|
||||
- **Deployment Options**:
|
||||
- **Desktop App**: `npm run tauri-build` (native desktop application)
|
||||
- **Web Server**: `npm run build` then serve dist/ folder
|
||||
- **Development**: `npm run tauri-dev` for desktop dev mode
|
||||
|
||||
#### Import Paths - CRITICAL
|
||||
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use @app/* for all imports
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { useFileContext } from "@app/contexts/FileContext";
|
||||
import { FileContext } from "@app/contexts/FileContext";
|
||||
|
||||
// ❌ WRONG - Do not use @core/* or @proprietary/* in normal code
|
||||
import { AppLayout } from "@core/components/AppLayout";
|
||||
import { useFileContext } from "@proprietary/contexts/FileContext";
|
||||
```
|
||||
|
||||
**Only use explicit aliases when:**
|
||||
- Building layer-specific override that wraps a lower layer's component
|
||||
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
|
||||
|
||||
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
|
||||
|
||||
#### Component Override Pattern (Stub/Shadow)
|
||||
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
|
||||
|
||||
**How it works:**
|
||||
1. Core defines stub component (returns null or no-op)
|
||||
2. Desktop/proprietary overrides with same path/name
|
||||
3. Core imports via `@app/*` - higher layer "shadows" core in those builds
|
||||
4. No `@ts-ignore`, no `isTauri()` checks, no runtime conditionals!
|
||||
|
||||
**Example - Desktop-specific footer:**
|
||||
|
||||
```typescript
|
||||
// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
|
||||
return null; // Stub - does nothing in web builds
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
|
||||
import { Box } from '@mantine/core';
|
||||
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
||||
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
|
||||
return (
|
||||
<Box className={className}>
|
||||
<BackendHealthIndicator />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
|
||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||
|
||||
export function RightRail() {
|
||||
return (
|
||||
<div>
|
||||
{/* In web builds: renders nothing (stub returns null) */}
|
||||
{/* In desktop builds: renders BackendHealthIndicator */}
|
||||
<RightRailFooterExtensions className="right-rail-footer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Build resolution:**
|
||||
- **Core build**: `@app/*` → `core/*` → Gets stub (returns null)
|
||||
- **Desktop build**: `@app/*` → `desktop/*` → Gets real implementation (shadows core)
|
||||
|
||||
**Benefits:**
|
||||
- No runtime checks or feature flags
|
||||
- Type-safe across all builds
|
||||
- Clean, readable code
|
||||
- Build-time optimization (dead code elimination)
|
||||
|
||||
#### Multi-Tool Workflow Architecture
|
||||
Frontend designed for **stateful document processing**:
|
||||
- Users upload PDFs once, then chain tools (split → merge → compress → view)
|
||||
- File state and processing results persist across tool switches
|
||||
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
|
||||
|
||||
#### FileContext - Central State Management
|
||||
**Location**: `frontend/src/core/contexts/FileContext.tsx`
|
||||
- **Active files**: Currently loaded PDFs and their variants
|
||||
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
|
||||
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
|
||||
- **IndexedDB persistence**: File storage with thumbnail caching
|
||||
- **Preview system**: Tools can preview results (e.g., Split → Viewer → back to Split) without context pollution
|
||||
|
||||
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
|
||||
|
||||
#### Processing Services
|
||||
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
|
||||
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
|
||||
- **fileStorage**: IndexedDB with LRU cache management
|
||||
|
||||
#### Memory Management Strategy
|
||||
**Why manual cleanup exists**: Large PDFs (up to 100GB+) through multiple tools accumulate:
|
||||
- PDF.js documents that need explicit .destroy() calls
|
||||
- Blob URLs from tool outputs that need revocation
|
||||
- Web Workers that need termination
|
||||
Without cleanup: browser crashes with memory leaks.
|
||||
|
||||
#### Tool Development
|
||||
|
||||
**Architecture**: Modular hook-based system with clear separation of concerns:
|
||||
|
||||
- **useToolOperation** (`frontend/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||
- Coordinates all tool operations with consistent interface
|
||||
- Integrates with FileContext for operation tracking
|
||||
- Handles validation, error handling, and UI state management
|
||||
|
||||
- **Supporting Hooks**:
|
||||
- **useToolState**: UI state management (loading, progress, error, files)
|
||||
- **useToolApiCalls**: HTTP requests and file processing
|
||||
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
|
||||
|
||||
- **Utilities**:
|
||||
- **toolErrorHandler**: Standardized error extraction and i18n support
|
||||
- **toolResponseProcessor**: API response handling (single/zip/custom)
|
||||
- **toolOperationTracker**: FileContext integration utilities
|
||||
|
||||
**Three Tool Patterns**:
|
||||
|
||||
**Pattern 1: Single-File Tools** (Individual processing)
|
||||
- Backend processes one file per API call
|
||||
- Set `multiFileEndpoint: false`
|
||||
- Examples: Compress, Rotate
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'compress',
|
||||
endpoint: '/api/v1/misc/compress-pdf',
|
||||
buildFormData: (params, file: File) => { /* single file */ },
|
||||
multiFileEndpoint: false,
|
||||
});
|
||||
```
|
||||
|
||||
**Pattern 2: Multi-File Tools** (Batch processing)
|
||||
- Backend accepts `MultipartFile[]` arrays in single API call
|
||||
- Set `multiFileEndpoint: true`
|
||||
- Examples: Split, Merge, Overlay
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'split',
|
||||
endpoint: '/api/v1/general/split-pages',
|
||||
buildFormData: (params, files: File[]) => { /* all files */ },
|
||||
multiFileEndpoint: true,
|
||||
filePrefix: 'split_',
|
||||
});
|
||||
```
|
||||
|
||||
**Pattern 3: Complex Tools** (Custom processing)
|
||||
- Tools with complex routing logic or non-standard processing
|
||||
- Provide `customProcessor` for full control
|
||||
- Examples: Convert, OCR
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'convert',
|
||||
customProcessor: async (params, files) => { /* custom logic */ },
|
||||
});
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
|
||||
- **Consistent**: All tools follow same pattern and interface
|
||||
- **Maintainable**: Single responsibility hooks, easy to test and modify
|
||||
- **i18n Ready**: Built-in internationalization support
|
||||
- **Type Safe**: Full TypeScript support with generic interfaces
|
||||
- **Memory Safe**: Automatic resource cleanup and blob URL management
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Project Structure
|
||||
- **Backend**: Spring Boot application
|
||||
- **Frontend**: React-based SPA in `/frontend` directory
|
||||
- **File Storage**: IndexedDB for client-side file persistence and thumbnails
|
||||
- **Internationalization**: JSON-based translations (converted from backend .properties)
|
||||
- **PDF Processing**: PDFBox for core PDF operations, LibreOffice for conversions, PDF.js for client-side rendering
|
||||
- **Security**: Spring Security with optional authentication (controlled by `DOCKER_ENABLE_SECURITY`)
|
||||
- **Configuration**: YAML-based configuration with environment variable overrides
|
||||
|
||||
### Controller Architecture
|
||||
- **API Controllers** (`src/main/java/.../controller/api/`): REST endpoints for PDF operations
|
||||
- Organized by function: converters, security, misc, pipeline
|
||||
- Follow pattern: `@RestController` + `@RequestMapping("/api/v1/...")`
|
||||
|
||||
### Key Components
|
||||
- **SPDFApplication.java**: Main application class with desktop UI and browser launching logic
|
||||
- **ConfigInitializer**: Handles runtime configuration and settings files
|
||||
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
|
||||
- **Security Layer**: Authentication, authorization, and user management (when enabled)
|
||||
|
||||
### Frontend Directory Structure
|
||||
The frontend is organized with a clear separation of concerns:
|
||||
|
||||
- **`frontend/src/core/`**: Main application code (shared, production-ready components)
|
||||
- **`core/components/`**: React components organized by feature
|
||||
- `core/components/tools/`: Individual PDF tool implementations
|
||||
- `core/components/viewer/`: PDF viewer components
|
||||
- `core/components/pageEditor/`: Page manipulation UI
|
||||
- `core/components/tooltips/`: Help tooltips for tools
|
||||
- `core/components/shared/`: Reusable UI components
|
||||
- **`core/contexts/`**: React Context providers
|
||||
- `FileContext.tsx`: Central file state management
|
||||
- `file/`: File reducer and selectors
|
||||
- `toolWorkflow/`: Tool workflow state
|
||||
- **`core/hooks/`**: Custom React hooks
|
||||
- `hooks/tools/`: Tool-specific operation hooks (one directory per tool)
|
||||
- `hooks/tools/shared/`: Shared hook utilities (useToolOperation, etc.)
|
||||
- **`core/constants/`**: Application constants and configuration
|
||||
- **`core/data/`**: Static data (tool taxonomy, etc.)
|
||||
- **`core/services/`**: Business logic services (PDF processing, storage, etc.)
|
||||
|
||||
- **`frontend/src/desktop/`**: Desktop-specific (Tauri) code
|
||||
- **`frontend/src/proprietary/`**: Proprietary/licensed features
|
||||
- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code
|
||||
- **`frontend/public/`**: Static assets served directly
|
||||
- `public/locales/`: Translation JSON files
|
||||
|
||||
### Component Architecture
|
||||
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
|
||||
- **Internationalization**:
|
||||
- Backend: `messages_*.properties` files
|
||||
- Frontend: JSON files in `frontend/public/locales/` (converted from .properties)
|
||||
- Conversion Script: `scripts/convert_properties_to_json.py`
|
||||
|
||||
### Configuration Modes
|
||||
- **Ultra-lite**: Basic PDF operations only
|
||||
- **Standard**: Full feature set
|
||||
- **Fat**: Pre-downloaded dependencies for air-gapped environments
|
||||
- **Security Mode**: Adds authentication, user management, and enterprise features
|
||||
|
||||
### Testing Strategy
|
||||
- **Integration Tests**: Cucumber tests in `testing/cucumber/`
|
||||
- **Docker Testing**: `test.sh` validates all Docker variants
|
||||
- **Manual Testing**: No unit tests currently - relies on UI and API testing
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Local Development**:
|
||||
- Backend: `./gradlew bootRun` (runs on localhost:8080)
|
||||
- Frontend: `cd frontend && npm run dev` (runs on localhost:5173, proxies to backend)
|
||||
2. **Docker Testing**: Use `./test.sh` before submitting PRs
|
||||
3. **Code Style**: Spotless enforces Google Java Format automatically
|
||||
4. **Translations**:
|
||||
- Backend: Use helper scripts in `/scripts` for multi-language updates
|
||||
- Frontend: Update JSON files in `frontend/public/locales/` or use conversion script
|
||||
5. **Documentation**: API docs auto-generated and available at `/swagger-ui/index.html`
|
||||
|
||||
## Frontend Architecture Status
|
||||
|
||||
- **Core Status**: React SPA architecture complete with multi-tool workflow support
|
||||
- **State Management**: FileContext handles all file operations and tool navigation
|
||||
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
|
||||
- **Tool Integration**: Modular hook architecture with `useToolOperation` orchestrator
|
||||
- Individual hooks: `useToolState`, `useToolApiCalls`, `useToolResources`
|
||||
- Utilities: `toolErrorHandler`, `toolResponseProcessor`, `toolOperationTracker`
|
||||
- Pattern: Each tool creates focused operation hook, UI consumes state/actions
|
||||
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
||||
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
||||
|
||||
## Translation Rules
|
||||
|
||||
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
|
||||
- Translation files are located in `frontend/public/locales/`
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
|
||||
- **Lombok**: Used extensively - ensure IDE plugin is installed
|
||||
- **File Persistence**:
|
||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
||||
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
|
||||
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
|
||||
- **Import Paths**: ALWAYS use `@app/*` for imports - never use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer
|
||||
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
|
||||
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
|
||||
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
|
||||
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
|
||||
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
|
||||
- **Adding Tools**: See `ADDING_TOOLS.md` for complete guide to creating new PDF tools
|
||||
|
||||
## Communication Style
|
||||
- Be direct and to the point
|
||||
- No apologies or conversational filler
|
||||
- Answer questions directly without preamble
|
||||
- Explain reasoning concisely when asked
|
||||
- Avoid unnecessary elaboration
|
||||
|
||||
## Decision Making
|
||||
- Ask clarifying questions before making assumptions
|
||||
- Stop and ask when uncertain about project-specific details
|
||||
- Confirm approach before making structural changes
|
||||
- Request guidance on preferences (cross-platform vs specific tools, etc.)
|
||||
- Verify understanding of requirements before proceeding
|
||||
+3
-3
@@ -11,7 +11,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
|
||||
**Stirling 2.0** is built using:
|
||||
|
||||
**Backend:**
|
||||
- Spring Boot (Java 21+, JDK 25 recommended)
|
||||
- Spring Boot (Java 17+, JDK 21 recommended)
|
||||
- PDFBox for core PDF operations
|
||||
- LibreOffice for document conversions
|
||||
- qpdf for PDF optimization
|
||||
@@ -44,7 +44,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
|
||||
|
||||
- Docker
|
||||
- Git
|
||||
- Java JDK 21 or later (JDK 25 recommended)
|
||||
- Java JDK 17 or later (JDK 21 recommended)
|
||||
- Node.js 18+ and npm (required for frontend development)
|
||||
- Gradle 7.0 or later (Included within the repo)
|
||||
- Rust and Cargo (required for Tauri desktop app development)
|
||||
@@ -59,7 +59,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
|
||||
cd Stirling-PDF
|
||||
```
|
||||
|
||||
2. Install Docker and JDK 21 (or JDK 25 recommended) if not already installed.
|
||||
2. Install Docker and JDK17 if not already installed.
|
||||
|
||||
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
|
||||
1. Only VSCode
|
||||
|
||||
-444
@@ -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`)
|
||||
@@ -6,14 +6,10 @@ Portions of this software are licensed as follows:
|
||||
|
||||
* All content that resides under the "app/proprietary/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
|
||||
* All content that resides under the "engine/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "engine/LICENSE".
|
||||
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
|
||||
* All content that resides under the "frontend/src/desktop/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE".
|
||||
* All content that resides under the "frontend/src/saas/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/saas/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is
|
||||
available under the MIT License as defined below.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -5,10 +5,10 @@ bootRun {
|
||||
spotless {
|
||||
java {
|
||||
target 'src/**/java/**/*.java'
|
||||
targetExclude 'src/main/java/org/apache/**'
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
toggleOffOn()
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
@@ -27,7 +27,6 @@ spotless {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
api 'com.google.guava:guava:33.4.8-jre'
|
||||
api 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||
api 'org.springframework.boot:spring-boot-starter-aspectj'
|
||||
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260102.1'
|
||||
|
||||
@@ -103,9 +103,8 @@ public class EndpointConfiguration {
|
||||
|
||||
// Rule 2: Functional-group override - check if endpoint belongs to any disabled functional
|
||||
// group
|
||||
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
|
||||
String group = entry.getKey();
|
||||
if (disabledGroups.contains(group) && entry.getValue().contains(endpoint)) {
|
||||
for (String group : endpointGroups.keySet()) {
|
||||
if (disabledGroups.contains(group) && endpointGroups.get(group).contains(endpoint)) {
|
||||
// Skip tool groups (qpdf, OCRmyPDF, Ghostscript, LibreOffice, etc.)
|
||||
if (!isToolGroup(group)) {
|
||||
log.debug(
|
||||
@@ -132,11 +131,10 @@ public class EndpointConfiguration {
|
||||
|
||||
// Rule 4: Single-dependency check - if no alternatives defined, check if endpoint belongs
|
||||
// to any disabled tool groups
|
||||
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
|
||||
String group = entry.getKey();
|
||||
for (String group : endpointGroups.keySet()) {
|
||||
if (isToolGroup(group)
|
||||
&& disabledGroups.contains(group)
|
||||
&& entry.getValue().contains(endpoint)) {
|
||||
&& endpointGroups.get(group).contains(endpoint)) {
|
||||
log.debug(
|
||||
"isEndpointEnabled('{}') -> false (single tool group '{}' disabled, no alternatives)",
|
||||
original,
|
||||
@@ -356,7 +354,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");
|
||||
@@ -374,6 +371,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Other", REMOVE_BLANKS);
|
||||
addEndpointToGroup("Other", "remove-annotations");
|
||||
addEndpointToGroup("Other", "get-info-on-pdf");
|
||||
addEndpointToGroup("Other", "remove-image-pdf");
|
||||
addEndpointToGroup("Other", "add-attachments");
|
||||
addEndpointToGroup("Other", "replace-invert-pdf");
|
||||
addEndpointToGroup("Other", "edit-table-of-contents");
|
||||
@@ -473,7 +471,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");
|
||||
@@ -489,6 +486,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", REMOVE_BLANKS);
|
||||
addEndpointToGroup("Java", "remove-annotations");
|
||||
addEndpointToGroup("Java", "pdf-to-text");
|
||||
addEndpointToGroup("Java", "remove-image-pdf");
|
||||
addEndpointToGroup("Java", "pdf-to-markdown");
|
||||
addEndpointToGroup("Java", "add-attachments");
|
||||
addEndpointToGroup("Java", "compress-pdf");
|
||||
@@ -647,9 +645,8 @@ public class EndpointConfiguration {
|
||||
}
|
||||
|
||||
// Check if endpoint belongs to any disabled functional group
|
||||
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
|
||||
String group = entry.getKey();
|
||||
if (disabledGroups.contains(group) && entry.getValue().contains(endpoint)) {
|
||||
for (String group : endpointGroups.keySet()) {
|
||||
if (disabledGroups.contains(group) && endpointGroups.get(group).contains(endpoint)) {
|
||||
if (!isToolGroup(group)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ package stirling.software.common.aop;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.*;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -28,7 +26,7 @@ import stirling.software.common.service.JobExecutorService;
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Order(20) // Lower precedence - executes AFTER audit aspects populate MDC
|
||||
@Order(0) // Highest precedence - executes before audit aspects
|
||||
public class AutoJobAspect {
|
||||
|
||||
private static final Duration RETRY_BASE_DELAY = Duration.ofMillis(100);
|
||||
@@ -72,29 +70,26 @@ public class AutoJobAspect {
|
||||
// No retries needed, simple execution
|
||||
return jobExecutorService.runJobGeneric(
|
||||
async,
|
||||
wrapWithMDC(
|
||||
() -> {
|
||||
try {
|
||||
// Note: Progress tracking is handled in
|
||||
// TaskManager/JobExecutorService
|
||||
// The trackProgress flag controls whether detailed progress is
|
||||
// stored
|
||||
// for REST API queries, not WebSocket notifications
|
||||
return joinPoint.proceed(args);
|
||||
} catch (Throwable ex) {
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution: {}",
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
if (ex instanceof RuntimeException) {
|
||||
throw (RuntimeException) ex;
|
||||
}
|
||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||
// BaseAppException
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
}),
|
||||
() -> {
|
||||
try {
|
||||
// Note: Progress tracking is handled in TaskManager/JobExecutorService
|
||||
// The trackProgress flag controls whether detailed progress is stored
|
||||
// for REST API queries, not WebSocket notifications
|
||||
return joinPoint.proceed(args);
|
||||
} catch (Throwable ex) {
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution: {}",
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
if (ex instanceof RuntimeException) {
|
||||
throw (RuntimeException) ex;
|
||||
}
|
||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||
// BaseAppException
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
},
|
||||
timeout,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
@@ -128,108 +123,114 @@ public class AutoJobAspect {
|
||||
|
||||
return jobExecutorService.runJobGeneric(
|
||||
async,
|
||||
wrapWithMDC(
|
||||
() -> {
|
||||
// Use iterative approach instead of recursion to avoid stack overflow
|
||||
Throwable lastException = null;
|
||||
() -> {
|
||||
// Use iterative approach instead of recursion to avoid stack overflow
|
||||
Throwable lastException = null;
|
||||
|
||||
// Attempt counter starts at 1 for first try
|
||||
for (int currentAttempt = 1;
|
||||
currentAttempt <= maxRetries;
|
||||
currentAttempt++) {
|
||||
try {
|
||||
if (trackProgress && async) {
|
||||
// Get jobId for progress tracking in TaskManager
|
||||
// This enables REST API progress queries, not WebSocket
|
||||
if (jobIdRef.get() == null) {
|
||||
jobIdRef.set(getJobIdFromContext());
|
||||
}
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Tracking progress for job {} (attempt {}/{})",
|
||||
jobId,
|
||||
currentAttempt,
|
||||
maxRetries);
|
||||
// Progress is tracked in TaskManager for REST API
|
||||
// access
|
||||
// No WebSocket notifications sent here
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to execute the operation
|
||||
return joinPoint.proceed(args);
|
||||
|
||||
} catch (Throwable ex) {
|
||||
lastException = ex;
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution (attempt"
|
||||
+ " {}/{}): {}",
|
||||
// Attempt counter starts at 1 for first try
|
||||
for (int currentAttempt = 1; currentAttempt <= maxRetries; currentAttempt++) {
|
||||
try {
|
||||
if (trackProgress && async) {
|
||||
// Get jobId for progress tracking in TaskManager
|
||||
// This enables REST API progress queries, not WebSocket
|
||||
if (jobIdRef.get() == null) {
|
||||
jobIdRef.set(getJobIdFromContext());
|
||||
}
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Tracking progress for job {} (attempt {}/{})",
|
||||
jobId,
|
||||
currentAttempt,
|
||||
maxRetries,
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
maxRetries);
|
||||
// Progress is tracked in TaskManager for REST API access
|
||||
// No WebSocket notifications sent here
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should retry
|
||||
if (currentAttempt < maxRetries) {
|
||||
log.info(
|
||||
"Retrying operation, attempt {}/{}",
|
||||
currentAttempt + 1,
|
||||
maxRetries);
|
||||
// Attempt to execute the operation
|
||||
return joinPoint.proceed(args);
|
||||
|
||||
if (trackProgress && async) {
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Recording retry attempt for job {} in TaskManager",
|
||||
jobId);
|
||||
// Retry info is tracked in TaskManager for REST API
|
||||
// access
|
||||
}
|
||||
}
|
||||
} catch (Throwable ex) {
|
||||
lastException = ex;
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution (attempt"
|
||||
+ " {}/{}): {}",
|
||||
currentAttempt,
|
||||
maxRetries,
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
|
||||
// Use sleep for retry delay
|
||||
// For sync jobs, both sleep and async are blocking at this
|
||||
// point
|
||||
// For async jobs, the delay occurs in the executor thread
|
||||
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
|
||||
// Check if we should retry
|
||||
if (currentAttempt < maxRetries) {
|
||||
log.info(
|
||||
"Retrying operation, attempt {}/{}",
|
||||
currentAttempt + 1,
|
||||
maxRetries);
|
||||
|
||||
try {
|
||||
Thread.sleep(delayMs);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.debug(
|
||||
"Retry delay interrupted for attempt {}/{}",
|
||||
currentAttempt,
|
||||
maxRetries);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// No more retries, we'll throw the exception after the loop
|
||||
break;
|
||||
if (trackProgress && async) {
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Recording retry attempt for job {} in TaskManager",
|
||||
jobId);
|
||||
// Retry info is tracked in TaskManager for REST API access
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all retries failed
|
||||
if (lastException != null) {
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
if (lastException instanceof RuntimeException) {
|
||||
throw (RuntimeException) lastException;
|
||||
// Use non-blocking delay for all retry attempts to avoid blocking
|
||||
// threads
|
||||
// For sync jobs this avoids starving the tomcat thread pool under
|
||||
// load
|
||||
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
|
||||
|
||||
// Execute the retry after a delay through the JobExecutorService
|
||||
// rather than blocking the current thread with sleep
|
||||
CompletableFuture<Object> delayedRetry = new CompletableFuture<>();
|
||||
|
||||
// Use a delayed executor for non-blocking delay
|
||||
CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS)
|
||||
.execute(
|
||||
() -> {
|
||||
// Continue the retry loop in the next iteration
|
||||
// We can't return from here directly since
|
||||
// we're in a Runnable
|
||||
delayedRetry.complete(null);
|
||||
});
|
||||
|
||||
// Wait for the delay to complete before continuing
|
||||
try {
|
||||
delayedRetry.join();
|
||||
} catch (Exception e) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||
// BaseAppException
|
||||
throw new RuntimeException(
|
||||
"Job failed after "
|
||||
+ maxRetries
|
||||
+ " attempts: "
|
||||
+ lastException.getMessage(),
|
||||
lastException);
|
||||
} else {
|
||||
// No more retries, we'll throw the exception after the loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This should never happen if lastException is properly tracked
|
||||
throw new RuntimeException("Job failed but no exception was recorded");
|
||||
}),
|
||||
// If we get here, all retries failed
|
||||
if (lastException != null) {
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
if (lastException instanceof RuntimeException) {
|
||||
throw (RuntimeException) lastException;
|
||||
}
|
||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||
// BaseAppException
|
||||
throw new RuntimeException(
|
||||
"Job failed after "
|
||||
+ maxRetries
|
||||
+ " attempts: "
|
||||
+ lastException.getMessage(),
|
||||
lastException);
|
||||
}
|
||||
|
||||
// This should never happen if lastException is properly tracked
|
||||
throw new RuntimeException("Job failed but no exception was recorded");
|
||||
},
|
||||
timeout,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
@@ -298,32 +299,4 @@ public class AutoJobAspect {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a supplier to propagate MDC context to background threads. Captures MDC on request
|
||||
* thread and restores it in the background thread. Ensures proper cleanup to prevent context
|
||||
* leakage across jobs in thread pools.
|
||||
*/
|
||||
private <T> Supplier<T> wrapWithMDC(Supplier<T> supplier) {
|
||||
final Map<String, String> captured = MDC.getCopyOfContextMap();
|
||||
return () -> {
|
||||
final Map<String, String> previous = MDC.getCopyOfContextMap();
|
||||
try {
|
||||
// Set the captured context (or clear if none was captured)
|
||||
if (captured != null) {
|
||||
MDC.setContextMap(new HashMap<>(captured));
|
||||
} else {
|
||||
MDC.clear();
|
||||
}
|
||||
return supplier.get();
|
||||
} finally {
|
||||
// Restore previous state (or clear if there was none)
|
||||
if (previous != null) {
|
||||
MDC.setContextMap(previous);
|
||||
} else {
|
||||
MDC.clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+10
-111
@@ -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() {
|
||||
@@ -405,11 +365,11 @@ public class ApplicationProperties {
|
||||
}
|
||||
|
||||
public boolean isSettingsValid() {
|
||||
return !ValidationUtils.isStringEmpty(this.issuer)
|
||||
&& !ValidationUtils.isStringEmpty(this.clientId)
|
||||
&& !ValidationUtils.isStringEmpty(this.clientSecret)
|
||||
&& !ValidationUtils.isCollectionEmpty(this.scopes)
|
||||
&& !ValidationUtils.isStringEmpty(this.useAsUsername);
|
||||
return !ValidationUtils.isStringEmpty(this.getIssuer())
|
||||
&& !ValidationUtils.isStringEmpty(this.getClientId())
|
||||
&& !ValidationUtils.isStringEmpty(this.getClientSecret())
|
||||
&& !ValidationUtils.isCollectionEmpty(this.getScopes())
|
||||
&& !ValidationUtils.isStringEmpty(this.getUseAsUsername());
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -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
|
||||
@@ -621,52 +575,19 @@ public class ApplicationProperties {
|
||||
}
|
||||
|
||||
public boolean isAnalyticsEnabled() {
|
||||
return this.enableAnalytics != null && this.enableAnalytics;
|
||||
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
|
||||
}
|
||||
|
||||
public boolean isPosthogEnabled() {
|
||||
// Treat null as enabled when analytics is enabled
|
||||
return this.isAnalyticsEnabled() && (this.enablePosthog == null || this.enablePosthog);
|
||||
return this.isAnalyticsEnabled()
|
||||
&& (this.getEnablePosthog() == null || this.getEnablePosthog());
|
||||
}
|
||||
|
||||
public boolean isScarfEnabled() {
|
||||
// Treat null as enabled when analytics is enabled
|
||||
return this.isAnalyticsEnabled() && (this.enableScarf == null || this.enableScarf);
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
return this.isAnalyticsEnabled()
|
||||
&& (this.getEnableScarf() == null || this.getEnableScarf());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -793,7 +714,6 @@ public class ApplicationProperties {
|
||||
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
|
||||
private boolean defaultHideUnavailableTools = false;
|
||||
private boolean defaultHideUnavailableConversions = false;
|
||||
private HideDisabledTools hideDisabledTools = new HideDisabledTools();
|
||||
|
||||
public String getAppNameNavbar() {
|
||||
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
|
||||
@@ -806,12 +726,6 @@ public class ApplicationProperties {
|
||||
}
|
||||
return "classic"; // default
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class HideDisabledTools {
|
||||
private boolean googleDrive = false;
|
||||
private boolean mobileQRScanner = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -999,15 +913,6 @@ public class ApplicationProperties {
|
||||
private boolean ssoAutoLogin;
|
||||
private boolean database;
|
||||
private CustomMetadata customMetadata = new CustomMetadata();
|
||||
private GoogleDrive googleDrive = new GoogleDrive();
|
||||
|
||||
@Data
|
||||
public static class GoogleDrive {
|
||||
private boolean enabled = false;
|
||||
private String clientId = "";
|
||||
private String apiKey = "";
|
||||
private String appId = "";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class CustomMetadata {
|
||||
@@ -1057,12 +962,6 @@ public class ApplicationProperties {
|
||||
private boolean enabled = true;
|
||||
private int level = 2; // 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
|
||||
private int retentionDays = 90;
|
||||
private boolean captureFileHash =
|
||||
false; // Capture SHA-256 hash of files (increases processing time)
|
||||
private boolean capturePdfAuthor =
|
||||
false; // Capture PDF author metadata (increases processing time)
|
||||
private boolean captureOperationResults =
|
||||
false; // Capture operation return values (not recommended, high volume)
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
-16
@@ -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;
|
||||
}
|
||||
@@ -42,7 +42,7 @@ public enum Role {
|
||||
// Using the fromString method to get the Role enum based on the roleId
|
||||
Role role = fromString(roleId);
|
||||
// Return the roleName of the found Role enum
|
||||
return role.roleName;
|
||||
return role.getRoleName();
|
||||
}
|
||||
|
||||
// Method to retrieve all role IDs and role names
|
||||
@@ -50,14 +50,14 @@ public enum Role {
|
||||
// Using LinkedHashMap to preserve order
|
||||
Map<String, String> roleDetails = new LinkedHashMap<>();
|
||||
for (Role role : Role.values()) {
|
||||
roleDetails.put(role.roleId, role.roleName);
|
||||
roleDetails.put(role.getRoleId(), role.getRoleName());
|
||||
}
|
||||
return roleDetails;
|
||||
}
|
||||
|
||||
public static Role fromString(String roleId) {
|
||||
for (Role role : Role.values()) {
|
||||
if (role.roleId.equalsIgnoreCase(roleId)) {
|
||||
if (role.getRoleId().equalsIgnoreCase(roleId)) {
|
||||
return role;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,13 +117,13 @@ public class Provider {
|
||||
+ ", clientName="
|
||||
+ getClientName()
|
||||
+ ", clientId="
|
||||
+ clientId
|
||||
+ getClientId()
|
||||
+ ", clientSecret="
|
||||
+ (clientSecret != null && !clientSecret.isEmpty() ? "*****" : "NULL")
|
||||
+ (getClientSecret() != null && !getClientSecret().isEmpty() ? "*****" : "NULL")
|
||||
+ ", scopes="
|
||||
+ getScopes()
|
||||
+ ", useAsUsername="
|
||||
+ useAsUsername
|
||||
+ getUseAsUsername()
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
@@ -23,9 +21,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@Slf4j
|
||||
public class FileStorage {
|
||||
|
||||
/** Holds the result of a stream-to-disk store operation: the file ID and the bytes written. */
|
||||
public record StoredFile(String fileId, long size) {}
|
||||
|
||||
@Value("${stirling.tempDir:/tmp/stirling-files}")
|
||||
private String tempDirPath;
|
||||
|
||||
@@ -109,40 +104,6 @@ public class FileStorage {
|
||||
return Files.readAllBytes(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a file by its ID as a streaming InputStream. The caller is responsible for closing
|
||||
* the returned stream.
|
||||
*
|
||||
* @param fileId The ID of the file to retrieve
|
||||
* @return A buffered InputStream for the file
|
||||
* @throws IOException If the file doesn't exist or can't be read
|
||||
*/
|
||||
public InputStream retrieveInputStream(String fileId) throws IOException {
|
||||
Path filePath = getFilePath(fileId);
|
||||
// Let Files.newInputStream throw NoSuchFileException naturally — avoids TOCTOU race
|
||||
// between exists-check and open when another thread may delete concurrently.
|
||||
return new BufferedInputStream(Files.newInputStream(filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store data from an InputStream as a file and return its unique ID and byte count. Streams
|
||||
* directly to disk without buffering the entire content in heap.
|
||||
*
|
||||
* @param inputStream The input stream to read from
|
||||
* @param originalName The original name of the file (unused, kept for API symmetry)
|
||||
* @return A {@link StoredFile} containing the file ID and the number of bytes written
|
||||
* @throws IOException If there is an error storing the file
|
||||
*/
|
||||
public StoredFile storeInputStream(InputStream inputStream, String originalName)
|
||||
throws IOException {
|
||||
String fileId = generateFileId();
|
||||
Path filePath = getFilePath(fileId);
|
||||
Files.createDirectories(filePath.getParent());
|
||||
long size = Files.copy(inputStream, filePath);
|
||||
log.debug("Stored input stream with ID: {}", fileId);
|
||||
return new StoredFile(fileId, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file by its ID
|
||||
*
|
||||
|
||||
@@ -316,7 +316,7 @@ public class JobExecutorService {
|
||||
filename =
|
||||
disposition.substring(
|
||||
disposition.indexOf("filename=") + 9,
|
||||
disposition.lastIndexOf('"'));
|
||||
disposition.lastIndexOf("\""));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ public class MobileScannerService {
|
||||
FILE_EXTENSION_PATTERN.matcher(safeFilename).replaceFirst("");
|
||||
String ext =
|
||||
safeFilename.contains(".")
|
||||
? safeFilename.substring(safeFilename.lastIndexOf('.'))
|
||||
? safeFilename.substring(safeFilename.lastIndexOf("."))
|
||||
: "";
|
||||
safeFilename = nameWithoutExt + "-" + counter + ext;
|
||||
filePath = sessionDir.resolve(safeFilename).normalize().toAbsolutePath();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+1
-14
@@ -226,11 +226,10 @@ public class SsrfProtectionService {
|
||||
}
|
||||
|
||||
private boolean isPrivateIPv4Range(String ip) {
|
||||
// Includes RFC1918, RFC6598, loopback, link-local, and unspecified addresses
|
||||
// Includes RFC1918, loopback, link-local, and unspecified addresses
|
||||
return ip.startsWith("10.")
|
||||
|| ip.startsWith("192.168.")
|
||||
|| (ip.startsWith("172.") && isInRange172(ip))
|
||||
|| (ip.startsWith("100.") && isInRange100(ip))
|
||||
|| ip.startsWith("169.254.")
|
||||
|| ip.startsWith("127.")
|
||||
|| "0.0.0.0".equals(ip);
|
||||
@@ -248,18 +247,6 @@ public class SsrfProtectionService {
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isInRange100(String ip) {
|
||||
String[] parts = ip.split("\\.");
|
||||
if (parts.length >= 2) {
|
||||
try {
|
||||
int secondOctet = Integer.parseInt(parts[1]);
|
||||
return secondOctet >= 64 && secondOctet <= 127;
|
||||
} catch (NumberFormatException e) {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isCloudMetadataAddress(String ip) {
|
||||
String normalizedIp = normalizeIpv4MappedAddress(ip);
|
||||
// Cloud metadata endpoints for AWS, GCP, Azure, Oracle Cloud, and IBM Cloud
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
@@ -19,6 +19,7 @@ import java.util.zip.ZipInputStream;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.ZipSecurity;
|
||||
|
||||
@@ -362,29 +363,39 @@ public class TaskManager {
|
||||
String zipFileId, String originalZipFileName) throws IOException {
|
||||
List<ResultFile> extractedFiles = new ArrayList<>();
|
||||
|
||||
try (InputStream fileStream = fileStorage.retrieveInputStream(zipFileId);
|
||||
ZipInputStream zipIn =
|
||||
ZipSecurity.createHardenedInputStream(
|
||||
new BufferedInputStream(fileStream))) {
|
||||
MultipartFile zipFile = fileStorage.retrieveFile(zipFileId);
|
||||
|
||||
try (ZipInputStream zipIn =
|
||||
ZipSecurity.createHardenedInputStream(
|
||||
new ByteArrayInputStream(zipFile.getBytes()))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zipIn.getNextEntry()) != null) {
|
||||
if (!entry.isDirectory()) {
|
||||
// Use buffered reading for memory safety
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[4096];
|
||||
int bytesRead;
|
||||
while ((bytesRead = zipIn.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, bytesRead);
|
||||
}
|
||||
byte[] fileContent = out.toByteArray();
|
||||
|
||||
String contentType = determineContentType(entry.getName());
|
||||
// storeInputStream returns the fileId and byte count — no extra stat needed
|
||||
FileStorage.StoredFile stored =
|
||||
fileStorage.storeInputStream(zipIn, entry.getName());
|
||||
String individualFileId = fileStorage.storeBytes(fileContent, entry.getName());
|
||||
|
||||
ResultFile resultFile =
|
||||
ResultFile.builder()
|
||||
.fileId(stored.fileId())
|
||||
.fileId(individualFileId)
|
||||
.fileName(entry.getName())
|
||||
.contentType(contentType)
|
||||
.fileSize(stored.size())
|
||||
.fileSize(fileContent.length)
|
||||
.build();
|
||||
|
||||
extractedFiles.add(resultFile);
|
||||
log.debug(
|
||||
"Extracted file: {} (size: {} bytes)", entry.getName(), stored.size());
|
||||
"Extracted file: {} (size: {} bytes)",
|
||||
entry.getName(),
|
||||
fileContent.length);
|
||||
}
|
||||
zipIn.closeEntry();
|
||||
}
|
||||
@@ -456,24 +467,4 @@ public class TaskManager {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the job key that owns a given file ID.
|
||||
*
|
||||
* @param fileId file identifier to look up
|
||||
* @return scoped job key if found, otherwise null
|
||||
*/
|
||||
public String findJobKeyByFileId(String fileId) {
|
||||
for (Map.Entry<String, JobResult> entry : jobResults.entrySet()) {
|
||||
JobResult jobResult = entry.getValue();
|
||||
if (jobResult.hasFiles()) {
|
||||
for (ResultFile resultFile : jobResult.getAllResultFiles()) {
|
||||
if (fileId.equals(resultFile.getFileId())) {
|
||||
return entry.getKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Enumeration;
|
||||
@@ -31,7 +30,15 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@UtilityClass
|
||||
public class CbzUtils {
|
||||
|
||||
public TempFile convertCbzToPdf(
|
||||
public byte[] convertCbzToPdf(
|
||||
MultipartFile cbzFile,
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
TempFileManager tempFileManager)
|
||||
throws IOException {
|
||||
return convertCbzToPdf(cbzFile, pdfDocumentFactory, tempFileManager, false);
|
||||
}
|
||||
|
||||
public byte[] convertCbzToPdf(
|
||||
MultipartFile cbzFile,
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
TempFileManager tempFileManager,
|
||||
@@ -57,90 +64,70 @@ public class CbzUtils {
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.createNewDocument();
|
||||
ZipFile zipFile = new ZipFile(tempFile.getFile())) {
|
||||
|
||||
// Pass 1: collect sorted image names (cheap just strings, no image data)
|
||||
List<String> sortedImageNames = new ArrayList<>();
|
||||
Enumeration<? extends ZipEntry> entries = zipFile.entries();
|
||||
List<ImageEntryData> imageEntries = new ArrayList<>();
|
||||
while (entries.hasMoreElements()) {
|
||||
ZipEntry entry = entries.nextElement();
|
||||
if (!entry.isDirectory() && isImageFile(entry.getName())) {
|
||||
sortedImageNames.add(entry.getName());
|
||||
try (InputStream is = zipFile.getInputStream(entry)) {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
is.transferTo(baos);
|
||||
imageEntries.add(
|
||||
new ImageEntryData(entry.getName(), baos.toByteArray()));
|
||||
} catch (IOException e) {
|
||||
log.warn("Error reading image {}: {}", entry.getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
sortedImageNames.sort(new NaturalOrderComparator());
|
||||
|
||||
if (sortedImageNames.isEmpty()) {
|
||||
imageEntries.sort(
|
||||
Comparator.comparing(ImageEntryData::name, new NaturalOrderComparator()));
|
||||
|
||||
if (imageEntries.isEmpty()) {
|
||||
throw ExceptionUtils.createCbzNoImagesException();
|
||||
}
|
||||
|
||||
// Pass 2: load ONE image at a time peak memory = max(single image)
|
||||
for (String imageName : sortedImageNames) {
|
||||
ZipEntry entry = zipFile.getEntry(imageName);
|
||||
try (InputStream is = zipFile.getInputStream(entry)) {
|
||||
ByteArrayOutputStream imgBaos = new ByteArrayOutputStream();
|
||||
is.transferTo(imgBaos);
|
||||
byte[] imageBytes = imgBaos.toByteArray();
|
||||
try {
|
||||
PDImageXObject pdImage =
|
||||
PDImageXObject.createFromByteArray(
|
||||
document, imageBytes, imageName);
|
||||
PDPage page =
|
||||
new PDPage(
|
||||
new PDRectangle(
|
||||
pdImage.getWidth(), pdImage.getHeight()));
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
contentStream.drawImage(pdImage, 0, 0);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Error processing image {}: {}", imageName, e.getMessage());
|
||||
for (ImageEntryData imageEntry : imageEntries) {
|
||||
try {
|
||||
PDImageXObject pdImage =
|
||||
PDImageXObject.createFromByteArray(
|
||||
document, imageEntry.data(), imageEntry.name());
|
||||
PDPage page =
|
||||
new PDPage(
|
||||
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
contentStream.drawImage(pdImage, 0, 0);
|
||||
}
|
||||
// imageBytes eligible for GC after each iteration
|
||||
} catch (IOException e) {
|
||||
log.warn("Error reading image {}: {}", imageName, e.getMessage());
|
||||
log.warn(
|
||||
"Error processing image {}: {}", imageEntry.name(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw ExceptionUtils.createCbzCorruptedImagesException();
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
byte[] pdfBytes = baos.toByteArray();
|
||||
|
||||
// Write to TempFile (not BAOS)
|
||||
TempFile pdfTempFile = new TempFile(tempFileManager, ".pdf");
|
||||
try {
|
||||
document.save(pdfTempFile.getFile());
|
||||
|
||||
if (optimizeForEbook) {
|
||||
try {
|
||||
byte[] pdfBytes = Files.readAllBytes(pdfTempFile.getPath());
|
||||
byte[] optimized = GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
|
||||
pdfTempFile.close();
|
||||
TempFile optimizedFile = new TempFile(tempFileManager, ".pdf");
|
||||
try {
|
||||
Files.write(optimizedFile.getPath(), optimized);
|
||||
return optimizedFile;
|
||||
} catch (Exception e) {
|
||||
optimizedFile.close();
|
||||
throw e;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Ghostscript optimization failed, returning unoptimized PDF",
|
||||
e);
|
||||
}
|
||||
// Apply Ghostscript optimization if requested
|
||||
if (optimizeForEbook) {
|
||||
try {
|
||||
return GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
|
||||
} catch (IOException e) {
|
||||
log.warn("Ghostscript optimization failed, returning unoptimized PDF", e);
|
||||
}
|
||||
|
||||
return pdfTempFile;
|
||||
} catch (Exception e) {
|
||||
pdfTempFile.close();
|
||||
throw e;
|
||||
}
|
||||
|
||||
return pdfBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,6 +175,8 @@ public class CbzUtils {
|
||||
return RegexPatternUtils.getInstance().getImageFilePattern().matcher(filename).matches();
|
||||
}
|
||||
|
||||
private record ImageEntryData(String name, byte[] data) {}
|
||||
|
||||
private class NaturalOrderComparator implements Comparator<String> {
|
||||
@Override
|
||||
public int compare(String s1, String s2) {
|
||||
|
||||
@@ -13,18 +13,11 @@ public class EmlToPdf {
|
||||
|
||||
public static String convertEmlToHtml(byte[] emlBytes, EmlToPdfRequest request)
|
||||
throws IOException {
|
||||
return convertEmlToHtml(emlBytes, request, null);
|
||||
}
|
||||
|
||||
public static String convertEmlToHtml(
|
||||
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
|
||||
throws IOException {
|
||||
EmlProcessingUtils.validateEmlInput(emlBytes);
|
||||
|
||||
EmlParser.EmailContent emailContent =
|
||||
EmlParser.extractEmailContent(emlBytes, request, customHtmlSanitizer);
|
||||
return EmlProcessingUtils.generateEnhancedEmailHtml(
|
||||
emailContent, request, customHtmlSanitizer);
|
||||
EmlParser.extractEmailContent(emlBytes, request, null);
|
||||
return EmlProcessingUtils.generateEnhancedEmailHtml(emailContent, request, null);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,9 @@ public class FileToPdf {
|
||||
sanitizeHtmlContent(
|
||||
new String(fileBytes, StandardCharsets.UTF_8),
|
||||
customHtmlSanitizer);
|
||||
Files.writeString(tempInputFile.getPath(), sanitizedHtml);
|
||||
Files.write(
|
||||
tempInputFile.getPath(),
|
||||
sanitizedHtml.getBytes(StandardCharsets.UTF_8));
|
||||
} else if (fileName.toLowerCase(Locale.ROOT).endsWith(".zip")) {
|
||||
Files.write(tempInputFile.getPath(), fileBytes);
|
||||
sanitizeHtmlFilesInZip(
|
||||
@@ -113,7 +115,8 @@ public class FileToPdf {
|
||||
new String(zipIn.readAllBytes(), StandardCharsets.UTF_8);
|
||||
String sanitizedContent =
|
||||
sanitizeHtmlContent(content, customHtmlSanitizer);
|
||||
Files.writeString(filePath, sanitizedContent);
|
||||
Files.write(
|
||||
filePath, sanitizedContent.getBytes(StandardCharsets.UTF_8));
|
||||
} else {
|
||||
Files.copy(zipIn, filePath);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSBase;
|
||||
@@ -75,10 +74,6 @@ public class FormUtils {
|
||||
*/
|
||||
private static final float SAME_LINE_THRESHOLD_PT = 10.0f;
|
||||
|
||||
private static final Pattern HEX_UUID_PATTERN =
|
||||
Pattern.compile("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$");
|
||||
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
|
||||
|
||||
/**
|
||||
* Returns a normalized logical type string for the supplied PDFBox field instance. Centralized
|
||||
* so all callers share identical mapping logic.
|
||||
@@ -1362,7 +1357,7 @@ public class FormUtils {
|
||||
if (da != null && !da.isBlank()) {
|
||||
// Standard DA looks like: /Helv 12 Tf 0 g
|
||||
// We want the number before 'Tf'
|
||||
String[] tokens = WHITESPACE_PATTERN.split(da);
|
||||
String[] tokens = da.split("\\s+");
|
||||
for (int i = 0; i < tokens.length; i++) {
|
||||
if ("Tf".equals(tokens[i]) && i > 0) {
|
||||
try {
|
||||
@@ -1462,8 +1457,9 @@ public class FormUtils {
|
||||
// Detect UUID-like hex strings (e.g. "cdc47b7041524571 7b2d93017fe77bf7")
|
||||
// Standard UUIDs are 32 hex characters; require at least that to avoid
|
||||
// false positives on short hex-like field names.
|
||||
String nospaces = WHITESPACE_PATTERN.matcher(simplified).replaceAll("");
|
||||
if (nospaces.length() >= 32 && HEX_UUID_PATTERN.matcher(nospaces).matches()) return true;
|
||||
String nospaces = simplified.replaceAll("\\s+", "");
|
||||
if (nospaces.length() >= 32 && nospaces.matches("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$"))
|
||||
return true;
|
||||
|
||||
return patterns.getGenericFieldNamePattern().matcher(simplified).matches()
|
||||
|| patterns.getSimpleFormFieldPattern().matcher(simplified).matches()
|
||||
|
||||
@@ -873,36 +873,6 @@ public class GeneralUtils {
|
||||
settingsYaml.saveOverride(settingsPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates multiple settings in a single transaction. This ensures that nested settings (e.g.,
|
||||
* oauth2.client.google.*) don't lose sibling values when partial updates are made.
|
||||
*
|
||||
* <p>Instead of multiple read-update-write cycles (which could cause race conditions), this
|
||||
* method loads the YAML once, applies all updates, and saves once.
|
||||
*
|
||||
* @param settingsMap Map of dotted-notation keys to values to update
|
||||
* @throws IOException if file read/write fails
|
||||
*/
|
||||
public void updateSettingsTransactional(Map<String, Object> settingsMap) throws IOException {
|
||||
if (settingsMap == null || settingsMap.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
|
||||
YamlHelper settingsYaml = new YamlHelper(settingsPath);
|
||||
|
||||
// Apply all updates to the same YamlHelper instance
|
||||
for (Map.Entry<String, Object> entry : settingsMap.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
String[] keyArray = key.split("\\.");
|
||||
settingsYaml.updateValue(Arrays.asList(keyArray), value);
|
||||
}
|
||||
|
||||
// Save only once after all updates are applied
|
||||
settingsYaml.saveOverride(settingsPath);
|
||||
}
|
||||
|
||||
/*
|
||||
* Machine fingerprint generation with better error logging and fallbacks.
|
||||
*
|
||||
|
||||
@@ -10,7 +10,6 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@@ -34,8 +33,6 @@ import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
@Slf4j
|
||||
public class PDFToFile {
|
||||
|
||||
private static final Pattern PATTERN =
|
||||
Pattern.compile("(!\\[.*?\\])\\((?!images/)([^/)][^)]*?)\\)");
|
||||
private final TempFileManager tempFileManager;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@@ -166,7 +163,7 @@ public class PDFToFile {
|
||||
private String updateImageReferences(String markdown) {
|
||||
// Match markdown image syntax: 
|
||||
// Only update if the path doesn't already start with images/
|
||||
return PATTERN.matcher(markdown).replaceAll("$1(images/$2)");
|
||||
return markdown.replaceAll("(!\\[.*?\\])\\((?!images/)([^/)][^)]*?)\\)", "$1(images/$2)");
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> processPdfToHtml(MultipartFile inputFile)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Locale;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
@@ -22,11 +22,8 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@Slf4j
|
||||
public class PdfToCbzUtils {
|
||||
|
||||
public static TempFile convertPdfToCbz(
|
||||
MultipartFile pdfFile,
|
||||
int dpi,
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
TempFileManager tempFileManager)
|
||||
public static byte[] convertPdfToCbz(
|
||||
MultipartFile pdfFile, int dpi, CustomPDFDocumentFactory pdfDocumentFactory)
|
||||
throws IOException {
|
||||
|
||||
validatePdfFile(pdfFile);
|
||||
@@ -36,7 +33,7 @@ public class PdfToCbzUtils {
|
||||
throw ExceptionUtils.createPdfNoPages();
|
||||
}
|
||||
|
||||
return createCbzFromPdf(document, dpi, tempFileManager);
|
||||
return createCbzFromPdf(document, dpi);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,49 +53,46 @@ public class PdfToCbzUtils {
|
||||
}
|
||||
}
|
||||
|
||||
private static TempFile createCbzFromPdf(
|
||||
PDDocument document, int dpi, TempFileManager tempFileManager) throws IOException {
|
||||
private static byte[] createCbzFromPdf(PDDocument document, int dpi) throws IOException {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
|
||||
|
||||
TempFile cbzTempFile = new TempFile(tempFileManager, ".cbz");
|
||||
try {
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(cbzTempFile.getPath()))) {
|
||||
try (ByteArrayOutputStream cbzOutputStream = new ByteArrayOutputStream();
|
||||
ZipOutputStream zipOut = new ZipOutputStream(cbzOutputStream)) {
|
||||
|
||||
int totalPages = document.getNumberOfPages();
|
||||
int totalPages = document.getNumberOfPages();
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
final int currentPage = pageIndex;
|
||||
try {
|
||||
BufferedImage image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
currentPage + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
currentPage, dpi, ImageType.RGB));
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
final int currentPage = pageIndex;
|
||||
try {
|
||||
BufferedImage image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
currentPage + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
currentPage, dpi, ImageType.RGB));
|
||||
|
||||
String imageFilename =
|
||||
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
|
||||
zipOut.putNextEntry(new ZipEntry(imageFilename));
|
||||
ImageIO.write(image, "PNG", zipOut);
|
||||
zipOut.closeEntry();
|
||||
String imageFilename =
|
||||
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
|
||||
ZipEntry zipEntry = new ZipEntry(imageFilename);
|
||||
zipOut.putNextEntry(zipEntry);
|
||||
|
||||
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
|
||||
// Re-throw OOM exceptions without wrapping
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
// Wrap other IOExceptions with context
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"CBZ creation for page " + (currentPage + 1), e);
|
||||
}
|
||||
ImageIO.write(image, "PNG", zipOut);
|
||||
zipOut.closeEntry();
|
||||
|
||||
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
|
||||
// Re-throw OOM exceptions without wrapping
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
// Wrap other IOExceptions with context
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"CBZ creation for page " + (currentPage + 1), e);
|
||||
}
|
||||
}
|
||||
return cbzTempFile;
|
||||
} catch (Exception e) {
|
||||
cbzTempFile.close();
|
||||
throw e;
|
||||
|
||||
zipOut.finish();
|
||||
return cbzOutputStream.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -219,31 +219,58 @@ public class PdfUtils {
|
||||
int maxWidth = 0;
|
||||
int totalHeight = 0;
|
||||
|
||||
// Using a map to store the calculated dimensions of each page size
|
||||
BufferedImage pdfSizeImage = null;
|
||||
int pdfSizeImageIndex = -1;
|
||||
|
||||
// Using a map to store the rendered dimensions of each page size
|
||||
// to avoid rendering the same page sizes multiple times
|
||||
HashMap<PdfRenderSettingsKey, PdfImageDimensionValue> pageSizes =
|
||||
new HashMap<>();
|
||||
for (int i = 0; i < pageCount; ++i) {
|
||||
final int pageIndex = i;
|
||||
PDPage page = document.getPage(i);
|
||||
PDRectangle cropBox = page.getCropBox();
|
||||
PDRectangle mediaBox = page.getMediaBox();
|
||||
int rotation = page.getRotation();
|
||||
PdfRenderSettingsKey settings =
|
||||
new PdfRenderSettingsKey(
|
||||
cropBox.getWidth(), cropBox.getHeight(), rotation);
|
||||
mediaBox.getWidth(), mediaBox.getHeight(), rotation);
|
||||
PdfImageDimensionValue dimension = pageSizes.get(settings);
|
||||
if (dimension == null) {
|
||||
float scale = DPI / 72f;
|
||||
int widthPx = (int) Math.max(Math.floor(cropBox.getWidth() * scale), 1);
|
||||
int heightPx =
|
||||
(int) Math.max(Math.floor(cropBox.getHeight() * scale), 1);
|
||||
if (rotation == 90 || rotation == 270) {
|
||||
int tmp = widthPx;
|
||||
widthPx = heightPx;
|
||||
heightPx = tmp;
|
||||
// Render the image to get the dimensions
|
||||
try {
|
||||
// Validate dimensions before rendering
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
page, pageIndex + 1, DPI);
|
||||
|
||||
pdfSizeImage =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
DPI,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, DPI, colorType));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage()
|
||||
.contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigExceedsArray",
|
||||
"PDF page {0} is too large to render at {1} DPI. The"
|
||||
+ " resulting image would exceed Java's maximum"
|
||||
+ " array size. Please try a lower DPI value"
|
||||
+ " (recommended: 150 or less).",
|
||||
i + 1,
|
||||
DPI);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
dimension = new PdfImageDimensionValue(widthPx, heightPx);
|
||||
pdfSizeImageIndex = i;
|
||||
dimension =
|
||||
new PdfImageDimensionValue(
|
||||
pdfSizeImage.getWidth(), pdfSizeImage.getHeight());
|
||||
pageSizes.put(settings, dimension);
|
||||
if (widthPx > maxWidth) {
|
||||
maxWidth = widthPx;
|
||||
if (pdfSizeImage.getWidth() > maxWidth) {
|
||||
maxWidth = pdfSizeImage.getWidth();
|
||||
}
|
||||
}
|
||||
totalHeight += dimension.height();
|
||||
@@ -257,32 +284,40 @@ public class PdfUtils {
|
||||
int currentHeight = 0;
|
||||
BufferedImage pageImage;
|
||||
|
||||
// Check if the first image is the last rendered image
|
||||
boolean firstImageAlreadyRendered = pdfSizeImageIndex == 0;
|
||||
|
||||
for (int i = 0; i < pageCount; ++i) {
|
||||
final int pageIndex = i;
|
||||
try {
|
||||
// Validate dimensions before rendering
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
document.getPage(pageIndex), pageIndex + 1, DPI);
|
||||
if (firstImageAlreadyRendered && i == 0) {
|
||||
pageImage = pdfSizeImage;
|
||||
} else {
|
||||
try {
|
||||
// Validate dimensions before rendering
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
document.getPage(pageIndex), pageIndex + 1, DPI);
|
||||
|
||||
pageImage =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
DPI,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, DPI, colorType));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage().contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigForDpi",
|
||||
"PDF page {0} is too large to render at {1} DPI. Please"
|
||||
+ " try a lower DPI value (recommended: 150 or"
|
||||
+ " less).",
|
||||
i + 1,
|
||||
DPI);
|
||||
pageImage =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
DPI,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, DPI, colorType));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage()
|
||||
.contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigForDpi",
|
||||
"PDF page {0} is too large to render at {1} DPI. Please"
|
||||
+ " try a lower DPI value (recommended: 150 or"
|
||||
+ " less).",
|
||||
i + 1,
|
||||
DPI);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Calculate the x-coordinate to center the image
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -191,7 +191,7 @@ public class YamlHelper {
|
||||
mappingNode.getValue().clear();
|
||||
mappingNode.getValue().addAll(updatedTuples);
|
||||
}
|
||||
updatedRootNode = node;
|
||||
setNewNode(node);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ public abstract class ReplaceAndInvertColorStrategy extends PDFFile {
|
||||
|
||||
public ReplaceAndInvertColorStrategy(MultipartFile file, ReplaceAndInvert replaceAndInvert) {
|
||||
setFileInput(file);
|
||||
this.replaceAndInvert = replaceAndInvert;
|
||||
setReplaceAndInvert(replaceAndInvert);
|
||||
}
|
||||
|
||||
public abstract InputStreamResource replace() throws IOException;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
-108
@@ -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));
|
||||
}
|
||||
}
|
||||
-210
@@ -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("<div class="test">'&'</div>", 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("<script>", 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 +1,44 @@
|
||||
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 {
|
||||
public class ErrorUtilsTest {
|
||||
|
||||
@Nested
|
||||
@DisplayName("exceptionToModel")
|
||||
class ExceptionToModelTests {
|
||||
@Test
|
||||
public void testExceptionToModel() {
|
||||
// Create a mock Model
|
||||
Model model = new org.springframework.ui.ExtendedModelMap();
|
||||
|
||||
@Test
|
||||
@DisplayName("should add error message to model")
|
||||
void addsErrorMessage() {
|
||||
Model model = mock(Model.class);
|
||||
Exception ex = new RuntimeException("test error");
|
||||
// Create a test exception
|
||||
Exception ex = new Exception("Test Exception");
|
||||
|
||||
ErrorUtils.exceptionToModel(model, ex);
|
||||
// Call the method under test
|
||||
Model resultModel = 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);
|
||||
}
|
||||
// Verify the result
|
||||
assertNotNull(resultModel);
|
||||
assertEquals("Test Exception", resultModel.getAttribute("errorMessage"));
|
||||
assertNotNull(resultModel.getAttribute("stackTrace"));
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("exceptionToModelView")
|
||||
class ExceptionToModelViewTests {
|
||||
@Test
|
||||
public void testExceptionToModelView() {
|
||||
// Create a mock Model
|
||||
Model model = new org.springframework.ui.ExtendedModelMap();
|
||||
|
||||
@Test
|
||||
@DisplayName("should create ModelAndView with error message")
|
||||
void addsErrorMessage() {
|
||||
Model model = mock(Model.class);
|
||||
Exception ex = new RuntimeException("view error");
|
||||
// Create a test exception
|
||||
Exception ex = new Exception("Test Exception");
|
||||
|
||||
ModelAndView result = ErrorUtils.exceptionToModelView(model, ex);
|
||||
// Call the method under test
|
||||
ModelAndView modelAndView = 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"));
|
||||
}
|
||||
// Verify the result
|
||||
assertNotNull(modelAndView);
|
||||
assertEquals("Test Exception", modelAndView.getModel().get("errorMessage"));
|
||||
assertNotNull(modelAndView.getModel().get("stackTrace"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 +1,194 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FileMonitorTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private FileMonitor createFileMonitor(Path watchDir) throws IOException {
|
||||
Predicate<Path> acceptAll = path -> true;
|
||||
RuntimePathConfig runtimePathConfig = mock(RuntimePathConfig.class);
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@Mock private Predicate<Path> pathFilter;
|
||||
|
||||
private FileMonitor fileMonitor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
when(runtimePathConfig.getPipelineWatchedFoldersPaths())
|
||||
.thenReturn(List.of(watchDir.toString()));
|
||||
return new FileMonitor(acceptAll, runtimePathConfig);
|
||||
.thenReturn(List.of(tempDir.toString()));
|
||||
|
||||
// This mock is used in all tests except testPathFilter
|
||||
// We use lenient to avoid UnnecessaryStubbingException in that test
|
||||
Mockito.lenient().when(pathFilter.test(any())).thenReturn(true);
|
||||
|
||||
fileMonitor = new FileMonitor(pathFilter, runtimePathConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConstructor_withValidDirectory() throws IOException {
|
||||
FileMonitor monitor = createFileMonitor(tempDir);
|
||||
assertNotNull(monitor);
|
||||
void testIsFileReadyForProcessing_OldFile() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("test-file.txt");
|
||||
Files.write(testFile, "test content".getBytes());
|
||||
|
||||
// Set modified time to 10 seconds ago (relative to test start time)
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
// File should be ready for processing as it was modified more than 5 seconds ago
|
||||
assertTrue(fileMonitor.isFileReadyForProcessing(testFile));
|
||||
}
|
||||
|
||||
@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()));
|
||||
void testIsFileReadyForProcessing_RecentFile() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Should not throw - just logs an error about non-existent path
|
||||
FileMonitor monitor = new FileMonitor(acceptAll, config);
|
||||
assertNotNull(monitor);
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("recent-file.txt");
|
||||
Files.write(testFile, "test content".getBytes());
|
||||
|
||||
// Set modified time to just now (relative to test start time)
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime));
|
||||
|
||||
// File should not be ready for processing as it was just modified
|
||||
assertFalse(fileMonitor.isFileReadyForProcessing(testFile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConstructor_withEmptyWatchedFolders() throws IOException {
|
||||
Predicate<Path> acceptAll = path -> true;
|
||||
RuntimePathConfig config = mock(RuntimePathConfig.class);
|
||||
when(config.getPipelineWatchedFoldersPaths()).thenReturn(List.of());
|
||||
void testIsFileReadyForProcessing_NonExistentFile() {
|
||||
// Create a path to a file that doesn't exist
|
||||
Path nonExistentFile = tempDir.resolve("non-existent-file.txt");
|
||||
|
||||
FileMonitor monitor = new FileMonitor(acceptAll, config);
|
||||
assertNotNull(monitor);
|
||||
// Non-existent file should not be ready for processing
|
||||
assertFalse(fileMonitor.isFileReadyForProcessing(nonExistentFile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTrackFiles_noEventsDoesNotThrow() throws IOException {
|
||||
FileMonitor monitor = createFileMonitor(tempDir);
|
||||
// Should not throw even when no events have occurred
|
||||
assertDoesNotThrow(() -> monitor.trackFiles());
|
||||
void testIsFileReadyForProcessing_LockedFile() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("locked-file.txt");
|
||||
Files.write(testFile, "test content".getBytes());
|
||||
|
||||
// Set modified time to 10 seconds ago (relative to test start time) to make sure it passes
|
||||
// the time check
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
// Verify the file is considered ready when it meets the time criteria
|
||||
assertTrue(
|
||||
fileMonitor.isFileReadyForProcessing(testFile),
|
||||
"File should be ready for processing when sufficiently old");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_nonExistentFile() throws IOException {
|
||||
FileMonitor monitor = createFileMonitor(tempDir);
|
||||
Path nonExistent = tempDir.resolve("nonexistent.pdf");
|
||||
void testPathFilter() throws IOException {
|
||||
// Use a simple lambda instead of a mock for better control
|
||||
Predicate<Path> pdfFilter = path -> path.toString().endsWith(".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");
|
||||
// Create a new FileMonitor with the PDF filter
|
||||
FileMonitor pdfMonitor = new FileMonitor(pdfFilter, runtimePathConfig);
|
||||
|
||||
// Create a PDF file
|
||||
Path pdfFile = tempDir.resolve("test.pdf");
|
||||
Files.write(pdfFile, "pdf content".getBytes());
|
||||
Files.setLastModifiedTime(pdfFile, FileTime.from(Instant.ofEpochMilli(1000000L)));
|
||||
|
||||
// Create a TXT file
|
||||
Path txtFile = tempDir.resolve("test.txt");
|
||||
Files.write(txtFile, "text content".getBytes());
|
||||
Files.setLastModifiedTime(txtFile, FileTime.from(Instant.ofEpochMilli(1000000L)));
|
||||
|
||||
// PDF file should be ready for processing
|
||||
assertTrue(pdfMonitor.isFileReadyForProcessing(pdfFile));
|
||||
|
||||
// Note: In the current implementation, FileMonitor.isFileReadyForProcessing()
|
||||
// doesn't check file filters directly - it only checks criteria like file existence
|
||||
// and modification time. The filtering is likely handled elsewhere in the workflow.
|
||||
|
||||
// To avoid test failures, we'll verify that the filter itself works correctly
|
||||
assertFalse(pdfFilter.test(txtFile), "PDF filter should reject txt files");
|
||||
assertTrue(pdfFilter.test(pdfFile), "PDF filter should accept pdf files");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_existingFile() throws IOException, InterruptedException {
|
||||
FileMonitor monitor = createFileMonitor(tempDir);
|
||||
Path testFile = tempDir.resolve("test.pdf");
|
||||
Files.writeString(testFile, "test content");
|
||||
void testIsFileReadyForProcessing_FileInUse() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Run trackFiles to process any events
|
||||
monitor.trackFiles();
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("in-use-file.txt");
|
||||
Files.write(testFile, "initial content".getBytes());
|
||||
|
||||
// The file might or might not be ready depending on timing,
|
||||
// but calling the method should not throw
|
||||
assertDoesNotThrow(() -> monitor.isFileReadyForProcessing(testFile));
|
||||
// Set modified time to 10 seconds ago (relative to test start time)
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
// First check that the file is ready when meeting time criteria
|
||||
assertTrue(
|
||||
fileMonitor.isFileReadyForProcessing(testFile),
|
||||
"File should be ready for processing when sufficiently old");
|
||||
|
||||
// After modifying the file to simulate closing, it should still be ready
|
||||
Files.write(testFile, "updated content".getBytes());
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
assertTrue(
|
||||
fileMonitor.isFileReadyForProcessing(testFile),
|
||||
"File should be ready for processing after updating");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTrackFiles_afterFileCreation() throws IOException {
|
||||
FileMonitor monitor = createFileMonitor(tempDir);
|
||||
void testIsFileReadyForProcessing_FileWithAbsolutePath() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a file in the watched directory
|
||||
Path testFile = tempDir.resolve("newfile.txt");
|
||||
Files.writeString(testFile, "hello");
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("absolute-path-file.txt");
|
||||
Files.write(testFile, "test content".getBytes());
|
||||
|
||||
// Track files should process the creation event
|
||||
assertDoesNotThrow(() -> monitor.trackFiles());
|
||||
// Set modified time to 10 seconds ago (relative to test start time)
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
// File should be ready for processing as it was modified more than 5 seconds ago
|
||||
// Use the absolute path to make sure it's handled correctly
|
||||
assertTrue(fileMonitor.isFileReadyForProcessing(testFile.toAbsolutePath()));
|
||||
}
|
||||
|
||||
@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()));
|
||||
void testIsFileReadyForProcessing_DirectoryInsteadOfFile() throws IOException {
|
||||
// Create a test directory
|
||||
Path testDir = tempDir.resolve("test-directory");
|
||||
Files.createDirectory(testDir);
|
||||
|
||||
FileMonitor monitor = new FileMonitor(rejectAll, config);
|
||||
assertNotNull(monitor);
|
||||
// Set modified time to 10 seconds ago
|
||||
Files.setLastModifiedTime(testDir, FileTime.from(Instant.ofEpochMilli(1000000L)));
|
||||
|
||||
// A directory should not be considered ready for processing
|
||||
boolean isReady = fileMonitor.isFileReadyForProcessing(testDir);
|
||||
assertFalse(isReady, "A directory should not be considered ready for processing");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 +1,115 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class FileToPdfTest {
|
||||
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
|
||||
import stirling.software.common.service.SsrfProtectionService;
|
||||
|
||||
@Test
|
||||
void testSanitizeZipFilename_normalFilename() {
|
||||
String result = FileToPdf.sanitizeZipFilename("document.html");
|
||||
assertEquals("document.html", result);
|
||||
public class FileToPdfTest {
|
||||
|
||||
private CustomHtmlSanitizer customHtmlSanitizer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SsrfProtectionService mockSsrfProtectionService = mock(SsrfProtectionService.class);
|
||||
stirling.software.common.model.ApplicationProperties mockApplicationProperties =
|
||||
mock(stirling.software.common.model.ApplicationProperties.class);
|
||||
stirling.software.common.model.ApplicationProperties.System mockSystem =
|
||||
mock(stirling.software.common.model.ApplicationProperties.System.class);
|
||||
|
||||
when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(true);
|
||||
when(mockApplicationProperties.getSystem()).thenReturn(mockSystem);
|
||||
when(mockSystem.isDisableSanitize()).thenReturn(false);
|
||||
|
||||
customHtmlSanitizer =
|
||||
new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the HTML to PDF conversion. This test expects an IOException when an empty HTML input is
|
||||
* provided.
|
||||
*/
|
||||
@Test
|
||||
void testSanitizeZipFilename_pathTraversal() {
|
||||
String result = FileToPdf.sanitizeZipFilename("../../etc/passwd");
|
||||
// Should remove ../ sequences
|
||||
assertFalse(result.contains(".."), "Path traversal sequences should be removed");
|
||||
public void testConvertHtmlToPdf() {
|
||||
HTMLToPdfRequest request = new HTMLToPdfRequest();
|
||||
byte[] fileBytes = new byte[0]; // Sample file bytes (empty input)
|
||||
String fileName = "test.html"; // Sample file name indicating an HTML file
|
||||
TempFileManager tempFileManager = mock(TempFileManager.class); // Mock TempFileManager
|
||||
|
||||
// Mock the temp file creation to return real temp files
|
||||
try {
|
||||
when(tempFileManager.createTempFile(anyString()))
|
||||
.thenReturn(Files.createTempFile("test", ".pdf").toFile())
|
||||
.thenReturn(Files.createTempFile("test", ".html").toFile());
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
// Expect an IOException to be thrown due to empty input or invalid weasyprint path
|
||||
Throwable thrown =
|
||||
assertThrows(
|
||||
Exception.class,
|
||||
() ->
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"/path/",
|
||||
request,
|
||||
fileBytes,
|
||||
fileName,
|
||||
tempFileManager,
|
||||
customHtmlSanitizer));
|
||||
assertNotNull(thrown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test sanitizeZipFilename with null or empty input. It should return an empty string in these
|
||||
* cases.
|
||||
*/
|
||||
@Test
|
||||
void testSanitizeZipFilename_driveLetterRemoved() {
|
||||
String result = FileToPdf.sanitizeZipFilename("C:\\Users\\test\\file.html");
|
||||
assertFalse(result.startsWith("C:"), "Drive letter should be removed");
|
||||
public void testSanitizeZipFilename_NullOrEmpty() {
|
||||
assertEquals("", FileToPdf.sanitizeZipFilename(null));
|
||||
assertEquals("", FileToPdf.sanitizeZipFilename(" "));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test sanitizeZipFilename to ensure it removes path traversal sequences. This includes
|
||||
* removing both forward and backward slash sequences.
|
||||
*/
|
||||
@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("\\"));
|
||||
public void testSanitizeZipFilename_RemovesTraversalSequences() {
|
||||
String input = "../some/../path/..\\to\\file.txt";
|
||||
String expected = "some/path/to/file.txt";
|
||||
|
||||
// Expect that the method replaces backslashes with forward slashes
|
||||
// and removes path traversal sequences
|
||||
assertEquals(expected, FileToPdf.sanitizeZipFilename(input));
|
||||
}
|
||||
|
||||
/** Test sanitizeZipFilename to ensure that it removes leading drive letters and slashes. */
|
||||
@Test
|
||||
void testSanitizeZipFilename_nullInput() {
|
||||
String result = FileToPdf.sanitizeZipFilename(null);
|
||||
assertEquals("", result, "Null input should return empty string");
|
||||
public void testSanitizeZipFilename_RemovesLeadingDriveAndSlashes() {
|
||||
String input = "C:\\folder\\file.txt";
|
||||
String expected = "folder/file.txt";
|
||||
assertEquals(expected, FileToPdf.sanitizeZipFilename(input));
|
||||
|
||||
input = "/folder/file.txt";
|
||||
expected = "folder/file.txt";
|
||||
assertEquals(expected, FileToPdf.sanitizeZipFilename(input));
|
||||
}
|
||||
|
||||
/** Test sanitizeZipFilename to verify that safe filenames remain unchanged. */
|
||||
@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");
|
||||
public void testSanitizeZipFilename_NoChangeForSafeNames() {
|
||||
String input = "folder/subfolder/file.txt";
|
||||
assertEquals(input, FileToPdf.sanitizeZipFilename(input));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
-147
@@ -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());
|
||||
}
|
||||
}
|
||||
+55
-86
@@ -2,110 +2,79 @@ package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferByte;
|
||||
import java.awt.image.DataBufferInt;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ImageProcessingUtilsTest {
|
||||
public 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());
|
||||
private static void fillImageWithColor(BufferedImage image) {
|
||||
for (int y = 0; y < image.getHeight(); y++) {
|
||||
for (int x = 0; x < image.getWidth(); x++) {
|
||||
image.setRGB(x, y, Color.RED.getRGB());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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());
|
||||
void testConvertColorTypeToGreyscale() {
|
||||
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
|
||||
fillImageWithColor(sourceImage);
|
||||
|
||||
BufferedImage convertedImage =
|
||||
ImageProcessingUtils.convertColorType(sourceImage, "greyscale");
|
||||
|
||||
assertNotNull(convertedImage);
|
||||
assertEquals(BufferedImage.TYPE_BYTE_GRAY, convertedImage.getType());
|
||||
assertEquals(sourceImage.getWidth(), convertedImage.getWidth());
|
||||
assertEquals(sourceImage.getHeight(), convertedImage.getHeight());
|
||||
|
||||
// Check if a pixel is correctly converted to greyscale
|
||||
Color grey = new Color(convertedImage.getRGB(0, 0));
|
||||
assertEquals(grey.getRed(), grey.getGreen());
|
||||
assertEquals(grey.getGreen(), grey.getBlue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertColorType_fullColor_returnsSameImage() {
|
||||
BufferedImage source = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
|
||||
BufferedImage result = ImageProcessingUtils.convertColorType(source, "fullcolor");
|
||||
assertSame(source, result);
|
||||
void testConvertColorTypeToBlackWhite() {
|
||||
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
|
||||
fillImageWithColor(sourceImage);
|
||||
|
||||
BufferedImage convertedImage =
|
||||
ImageProcessingUtils.convertColorType(sourceImage, "blackwhite");
|
||||
|
||||
assertNotNull(convertedImage);
|
||||
assertEquals(BufferedImage.TYPE_BYTE_BINARY, convertedImage.getType());
|
||||
assertEquals(sourceImage.getWidth(), convertedImage.getWidth());
|
||||
assertEquals(sourceImage.getHeight(), convertedImage.getHeight());
|
||||
|
||||
// Check if a pixel is converted correctly (binary image will be either black or white)
|
||||
int rgb = convertedImage.getRGB(0, 0);
|
||||
assertTrue(rgb == Color.BLACK.getRGB() || rgb == Color.WHITE.getRGB());
|
||||
}
|
||||
|
||||
@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);
|
||||
void testConvertColorTypeToFullColor() {
|
||||
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
|
||||
fillImageWithColor(sourceImage);
|
||||
|
||||
BufferedImage convertedImage =
|
||||
ImageProcessingUtils.convertColorType(sourceImage, "fullcolor");
|
||||
|
||||
assertNotNull(convertedImage);
|
||||
assertEquals(sourceImage, convertedImage);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
void testConvertColorTypeInvalid() {
|
||||
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
|
||||
fillImageWithColor(sourceImage);
|
||||
|
||||
@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);
|
||||
}
|
||||
BufferedImage convertedImage =
|
||||
ImageProcessingUtils.convertColorType(sourceImage, "invalidtype");
|
||||
|
||||
@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);
|
||||
assertNotNull(convertedImage);
|
||||
assertEquals(sourceImage, convertedImage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 +1,736 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.ColorModel;
|
||||
import java.awt.image.RenderedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
class PdfUtilsTest {
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
|
||||
@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);
|
||||
public class PdfUtilsTest {
|
||||
|
||||
@Test
|
||||
void testTextToPageSize() {
|
||||
assertEquals(PDRectangle.A0, PdfUtils.textToPageSize("A0"));
|
||||
assertEquals(PDRectangle.A1, PdfUtils.textToPageSize("A1"));
|
||||
assertEquals(PDRectangle.A2, PdfUtils.textToPageSize("A2"));
|
||||
assertEquals(PDRectangle.A3, PdfUtils.textToPageSize("A3"));
|
||||
assertEquals(PDRectangle.A4, PdfUtils.textToPageSize("A4"));
|
||||
assertEquals(PDRectangle.A5, PdfUtils.textToPageSize("A5"));
|
||||
assertEquals(PDRectangle.A6, PdfUtils.textToPageSize("A6"));
|
||||
assertEquals(PDRectangle.LETTER, PdfUtils.textToPageSize("LETTER"));
|
||||
assertEquals(PDRectangle.LEGAL, PdfUtils.textToPageSize("LEGAL"));
|
||||
assertThrows(IllegalArgumentException.class, () -> PdfUtils.textToPageSize("INVALID"));
|
||||
}
|
||||
|
||||
@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);
|
||||
void testGetAllImages() throws Exception {
|
||||
// Root resources
|
||||
PDResources root = mock(PDResources.class);
|
||||
|
||||
COSName im1 = COSName.getPDFName("Im1");
|
||||
COSName form1 = COSName.getPDFName("Form1");
|
||||
COSName other1 = COSName.getPDFName("Other1");
|
||||
when(root.getXObjectNames()).thenReturn(Arrays.asList(im1, form1, other1));
|
||||
|
||||
// Direct image at root
|
||||
PDImageXObject imgXObj1 = mock(PDImageXObject.class);
|
||||
BufferedImage img1 = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB);
|
||||
when(imgXObj1.getImage()).thenReturn(img1);
|
||||
when(root.getXObject(im1)).thenReturn(imgXObj1);
|
||||
|
||||
// "Other" XObject that should be ignored
|
||||
PDXObject otherXObj = mock(PDXObject.class);
|
||||
when(root.getXObject(other1)).thenReturn(otherXObj);
|
||||
|
||||
// Form XObject with its own resources
|
||||
PDFormXObject formXObj = mock(PDFormXObject.class);
|
||||
PDResources formRes = mock(PDResources.class);
|
||||
when(formXObj.getResources()).thenReturn(formRes);
|
||||
when(root.getXObject(form1)).thenReturn(formXObj);
|
||||
|
||||
// Inside the form: one image and a nested form
|
||||
COSName im2 = COSName.getPDFName("Im2");
|
||||
COSName nestedForm = COSName.getPDFName("NestedForm");
|
||||
when(formRes.getXObjectNames()).thenReturn(Arrays.asList(im2, nestedForm));
|
||||
|
||||
PDImageXObject imgXObj2 = mock(PDImageXObject.class);
|
||||
BufferedImage img2 = new BufferedImage(3, 3, BufferedImage.TYPE_INT_RGB);
|
||||
when(imgXObj2.getImage()).thenReturn(img2);
|
||||
when(formRes.getXObject(im2)).thenReturn(imgXObj2);
|
||||
|
||||
PDFormXObject nestedFormXObj = mock(PDFormXObject.class);
|
||||
PDResources nestedRes = mock(PDResources.class);
|
||||
when(nestedFormXObj.getResources()).thenReturn(nestedRes);
|
||||
when(formRes.getXObject(nestedForm)).thenReturn(nestedFormXObj);
|
||||
|
||||
// Deep nest: another image
|
||||
COSName im3 = COSName.getPDFName("Im3");
|
||||
when(nestedRes.getXObjectNames()).thenReturn(List.of(im3));
|
||||
|
||||
PDImageXObject imgXObj3 = mock(PDImageXObject.class);
|
||||
BufferedImage img3 = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB);
|
||||
when(imgXObj3.getImage()).thenReturn(img3);
|
||||
when(nestedRes.getXObject(im3)).thenReturn(imgXObj3);
|
||||
|
||||
// Act
|
||||
List<RenderedImage> result = PdfUtils.getAllImages(root);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
3, result.size(), "It should find exactly 3 images (root + form + nested form).");
|
||||
assertTrue(
|
||||
result.containsAll(List.of(img1, img2, img3)),
|
||||
"All expected images must be present.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void textToPageSize_invalidSize_throwsException() {
|
||||
assertThrows(Exception.class, () -> PdfUtils.textToPageSize("INVALID"));
|
||||
void testPageCountComparators() throws Exception {
|
||||
PDDocument doc1 = new PDDocument();
|
||||
doc1.addPage(new PDPage());
|
||||
doc1.addPage(new PDPage());
|
||||
doc1.addPage(new PDPage());
|
||||
assertTrue(PdfUtils.pageCount(doc1, 2, "greater"));
|
||||
|
||||
PDDocument doc2 = new PDDocument();
|
||||
doc2.addPage(new PDPage());
|
||||
doc2.addPage(new PDPage());
|
||||
doc2.addPage(new PDPage());
|
||||
assertTrue(PdfUtils.pageCount(doc2, 3, "equal"));
|
||||
|
||||
PDDocument doc3 = new PDDocument();
|
||||
doc3.addPage(new PDPage());
|
||||
doc3.addPage(new PDPage());
|
||||
assertTrue(PdfUtils.pageCount(doc3, 5, "less"));
|
||||
|
||||
PDDocument doc4 = new PDDocument();
|
||||
doc4.addPage(new PDPage());
|
||||
assertThrows(IllegalArgumentException.class, () -> PdfUtils.pageCount(doc4, 1, "bad"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAllImages_emptyResources_returnsEmptyList() throws IOException {
|
||||
void testPageSize() throws Exception {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
PDRectangle rect = page.getMediaBox();
|
||||
String expected = rect.getWidth() + "x" + rect.getHeight();
|
||||
assertTrue(PdfUtils.pageSize(doc, expected));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOverlayImage() throws Exception {
|
||||
PDDocument doc = new PDDocument();
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream pdfOut = new ByteArrayOutputStream();
|
||||
doc.save(pdfOut);
|
||||
doc.close();
|
||||
|
||||
BufferedImage image = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = image.createGraphics();
|
||||
g.setColor(Color.RED);
|
||||
g.fillRect(0, 0, 10, 10);
|
||||
g.dispose();
|
||||
ByteArrayOutputStream imgOut = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "png", imgOut);
|
||||
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
|
||||
|
||||
byte[] result =
|
||||
PdfUtils.overlayImage(
|
||||
factory, pdfOut.toByteArray(), imgOut.toByteArray(), 0, 0, false);
|
||||
try (PDDocument resultDoc = factory.load(result)) {
|
||||
assertEquals(1, resultDoc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// Additional tests (added without modifying existing ones)
|
||||
// ===============================================================
|
||||
|
||||
/* Helper: create a colored test image */
|
||||
private static BufferedImage createImage(int w, int h, Color color) {
|
||||
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setColor(color);
|
||||
g.fillRect(0, 0, w, h);
|
||||
g.dispose();
|
||||
return img;
|
||||
}
|
||||
|
||||
/* Helper: create a factory like in existing tests */
|
||||
private static CustomPDFDocumentFactory factory() {
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
return new CustomPDFDocumentFactory(meta);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertPdfToPdfImage: creates image-PDF with same page count")
|
||||
void convertPdfToPdfImage_shouldCreateImagePdfWithSamePageCount() 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());
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("Hello PDF");
|
||||
cs.endText();
|
||||
}
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p2);
|
||||
|
||||
PDDocument out = PdfUtils.convertPdfToPdfImage(doc);
|
||||
assertNotNull(out);
|
||||
assertEquals(2, out.getNumberOfPages(), "Page count should be preserved");
|
||||
out.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAllImages_withImage_returnsImage() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
@DisplayName("imageToPdf: PNG -> single-page PDF (static ImageProcessingUtils mocked)")
|
||||
void imageToPdf_shouldCreatePdfFromPng() throws Exception {
|
||||
BufferedImage img = createImage(320, 200, Color.RED);
|
||||
ByteArrayOutputStream pngOut = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "png", pngOut);
|
||||
|
||||
BufferedImage bufferedImage = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
|
||||
PDImageXObject pdImage = LosslessFactory.createFromImage(doc, bufferedImage);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("files", "test.png", "image/png", pngOut.toByteArray());
|
||||
|
||||
PDResources resources = new PDResources();
|
||||
resources.add(pdImage);
|
||||
page.setResources(resources);
|
||||
try (MockedStatic<ImageProcessingUtils> mocked = mockStatic(ImageProcessingUtils.class)) {
|
||||
// Assume: loadImageWithExifOrientation/convertColorType exist – static mock
|
||||
mocked.when(() -> ImageProcessingUtils.loadImageWithExifOrientation(any()))
|
||||
.thenReturn(img);
|
||||
mocked.when(
|
||||
() ->
|
||||
ImageProcessingUtils.convertColorType(
|
||||
any(BufferedImage.class), anyString()))
|
||||
.thenAnswer(inv -> inv.getArgument(0, BufferedImage.class));
|
||||
|
||||
List<RenderedImage> images = PdfUtils.getAllImages(page.getResources());
|
||||
assertEquals(1, images.size());
|
||||
byte[] pdfBytes =
|
||||
PdfUtils.imageToPdf(
|
||||
new MockMultipartFile[] {file},
|
||||
"maintainAspectRatio",
|
||||
true,
|
||||
"RGB",
|
||||
factory());
|
||||
|
||||
try (PDDocument result = factory().load(pdfBytes)) {
|
||||
assertEquals(1, result.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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));
|
||||
@DisplayName("imageToPdf: JPEG -> single-page PDF (JPEGFactory path)")
|
||||
void imageToPdf_shouldCreatePdfFromJpeg_UsingJpegFactory() throws Exception {
|
||||
BufferedImage img = createImage(640, 360, Color.BLUE);
|
||||
ByteArrayOutputStream jpgOut = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "jpg", jpgOut);
|
||||
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("files", "photo.jpg", "image/jpeg", jpgOut.toByteArray());
|
||||
|
||||
try (MockedStatic<ImageProcessingUtils> mocked = mockStatic(ImageProcessingUtils.class)) {
|
||||
mocked.when(() -> ImageProcessingUtils.loadImageWithExifOrientation(any()))
|
||||
.thenReturn(img);
|
||||
mocked.when(
|
||||
() ->
|
||||
ImageProcessingUtils.convertColorType(
|
||||
any(BufferedImage.class), anyString()))
|
||||
.thenAnswer(inv -> inv.getArgument(0, BufferedImage.class));
|
||||
|
||||
byte[] pdfBytes =
|
||||
PdfUtils.imageToPdf(
|
||||
new MockMultipartFile[] {file}, "fillPage", false, "RGB", factory());
|
||||
|
||||
try (PDDocument result = factory().load(pdfBytes)) {
|
||||
assertEquals(1, result.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasTextOnPage_noText_returnsFalse() throws IOException {
|
||||
@DisplayName("addImageToDocument: fitDocumentToImage -> page size = image size")
|
||||
void addImageToDocument_shouldUseImageSizeForPage_whenFitDocumentToImage() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
assertFalse(PdfUtils.hasTextOnPage(page, "hello"));
|
||||
BufferedImage img = createImage(300, 500, Color.GREEN);
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
|
||||
|
||||
PdfUtils.addImageToDocument(doc, ximg, "fitDocumentToImage", false);
|
||||
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
PDRectangle box = doc.getPage(0).getMediaBox();
|
||||
assertEquals(300, (int) box.getWidth());
|
||||
assertEquals(500, (int) box.getHeight());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageCount_greaterComparator_correct() throws IOException {
|
||||
@DisplayName("addImageToDocument: autoRotate rotates A4 for landscape image")
|
||||
void addImageToDocument_shouldRotateA4_whenAutoRotateAndLandscape() 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"));
|
||||
BufferedImage img = createImage(800, 400, Color.ORANGE); // Landscape
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
|
||||
|
||||
PdfUtils.addImageToDocument(doc, ximg, "maintainAspectRatio", true);
|
||||
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
PDRectangle box = doc.getPage(0).getMediaBox();
|
||||
assertTrue(
|
||||
box.getWidth() > box.getHeight(),
|
||||
"A4 should be landscape when auto-rotate + landscape");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageCount_equalComparator_correct() throws IOException {
|
||||
@DisplayName("addImageToDocument: fillPage runs without errors")
|
||||
void addImageToDocument_fillPage_executes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
doc.addPage(new PDPage());
|
||||
assertTrue(PdfUtils.pageCount(doc, 2, "equal"));
|
||||
BufferedImage img = createImage(200, 200, Color.MAGENTA);
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
|
||||
|
||||
PdfUtils.addImageToDocument(doc, ximg, "fillPage", false);
|
||||
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageCount_lessComparator_correct() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
assertTrue(PdfUtils.pageCount(doc, 5, "less"));
|
||||
@DisplayName("overlayImage: everyPage=true overlays all pages")
|
||||
void overlayImage_shouldOverlayAllPages_whenEveryPageTrue() throws IOException {
|
||||
CustomPDFDocumentFactory factory = factory();
|
||||
|
||||
// Create PDF with 2 pages
|
||||
byte[] basePdf;
|
||||
try (PDDocument doc = factory.createNewDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
basePdf = baos.toByteArray();
|
||||
}
|
||||
|
||||
// Create image bytes
|
||||
BufferedImage img = createImage(50, 50, Color.BLACK);
|
||||
ByteArrayOutputStream pngOut = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "png", pngOut);
|
||||
|
||||
byte[] result = PdfUtils.overlayImage(factory, basePdf, pngOut.toByteArray(), 10, 10, true);
|
||||
|
||||
try (PDDocument out = factory.load(result)) {
|
||||
assertEquals(2, out.getNumberOfPages(), "Page count remains identical");
|
||||
}
|
||||
}
|
||||
|
||||
/* Helper function: document with text on page1/page2 */
|
||||
private static PDDocument createDocWithText(String p1, String p2) throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
|
||||
PDPage page1 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page1);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, page1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText(p1);
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
PDPage page2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page2);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, page2, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText(p2);
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("containsTextInFile: pagesToCheck='all' finds text")
|
||||
void containsTextInFile_allPages_true() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("alpha", "beta")) {
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "beta", "all"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageCount_invalidComparator_throwsException() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
assertThrows(Exception.class, () -> PdfUtils.pageCount(doc, 1, "invalid"));
|
||||
@DisplayName("containsTextInFile: single page '2' finds text")
|
||||
void containsTextInFile_singlePage_two_true() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("alpha", "beta")) {
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "beta", "2"));
|
||||
}
|
||||
}
|
||||
|
||||
@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));
|
||||
@DisplayName("containsTextInFile: range '1-1' finds text on page 1")
|
||||
void containsTextInFile_range_oneToOne_true() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("findme", "other")) {
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "findme", "1-1"));
|
||||
}
|
||||
}
|
||||
|
||||
@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"));
|
||||
@DisplayName("containsTextInFile: list '1,2' finds text (whitespace robust)")
|
||||
void containsTextInFile_list_pages_true() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("foo", "bar")) {
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "bar", " 1 , 2 "));
|
||||
}
|
||||
}
|
||||
|
||||
@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"));
|
||||
@DisplayName("containsTextInFile: text not present -> false")
|
||||
void containsTextInFile_textNotPresent_false() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("xxx", "yyy")) {
|
||||
assertFalse(PdfUtils.containsTextInFile(doc, "zzz", "all"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageCount_equalComparator_false() throws IOException {
|
||||
@DisplayName("pageSize: different size returns false")
|
||||
void pageSize_shouldReturnFalse_whenSizeDoesNotMatch() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
doc.addPage(new PDPage());
|
||||
assertFalse(PdfUtils.pageCount(doc, 3, "equal"));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
assertFalse(PdfUtils.pageSize(doc, "600x842"));
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== New: convertFromPdf – coverage =====================
|
||||
|
||||
@Test
|
||||
@DisplayName("convertFromPdf: singleImage=true creates combined PNG file (readable)")
|
||||
void convertFromPdf_singleImagePng_combinedReadable() throws Exception {
|
||||
// Create two-page PDF
|
||||
byte[] pdfBytes;
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
pdfBytes = baos.toByteArray();
|
||||
}
|
||||
|
||||
byte[] imageBytes =
|
||||
PdfUtils.convertFromPdf(
|
||||
factory, pdfBytes, "png", ImageType.RGB, true, 72, "test.pdf", false);
|
||||
|
||||
// Should be readable as a single combined PNG image
|
||||
BufferedImage img = ImageIO.read(new java.io.ByteArrayInputStream(imageBytes));
|
||||
assertNotNull(img, "PNG should be readable");
|
||||
assertTrue(img.getWidth() > 0 && img.getHeight() > 0, "Image dimensions > 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"convertFromPdf: singleImage=false returns ZIP with PNG entries (first image readable)")
|
||||
void convertFromPdf_multiImagePng_firstReadable() throws Exception {
|
||||
// Create two-page PDF
|
||||
byte[] pdfBytes;
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
pdfBytes = baos.toByteArray();
|
||||
}
|
||||
|
||||
// Act: singleImage=false -> ZIP with separate images
|
||||
byte[] zipBytes =
|
||||
PdfUtils.convertFromPdf(
|
||||
factory, pdfBytes, "png", ImageType.RGB, false, 72, "test.pdf", false);
|
||||
|
||||
// Assert: open ZIP, read first entry as PNG
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
|
||||
ZipEntry entry = zis.getNextEntry();
|
||||
assertNotNull(entry, "ZIP should contain at least one entry");
|
||||
|
||||
ByteArrayOutputStream imgOut = new ByteArrayOutputStream();
|
||||
zis.transferTo(imgOut);
|
||||
BufferedImage first = ImageIO.read(new ByteArrayInputStream(imgOut.toByteArray()));
|
||||
|
||||
assertNotNull(first, "First PNG entry should be readable");
|
||||
assertTrue(first.getWidth() > 0 && first.getHeight() > 0, "Image dimensions > 0");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageCount_lessComparator_false() throws IOException {
|
||||
@DisplayName("hasText: detects phrase on selected pages ('1', '2', 'all')")
|
||||
void hasText_shouldDetectPhrase_onSelectedPages() throws Exception {
|
||||
// Arrange: PDF with 2 pages and text
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
doc.addPage(new PDPage());
|
||||
doc.addPage(new PDPage());
|
||||
assertFalse(PdfUtils.pageCount(doc, 2, "less"));
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("alpha on page 1");
|
||||
cs.endText();
|
||||
}
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("beta on page 2");
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasText(doc, "1", "alpha"), "Page 1 should contain 'alpha'");
|
||||
}
|
||||
|
||||
// For further checks, create new doc with identical content
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("alpha on page 1");
|
||||
cs.endText();
|
||||
}
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("beta on page 2");
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasText(doc, "2", "beta"), "Page 2 should contain 'beta'");
|
||||
}
|
||||
|
||||
// Third doc for 'all'
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("gamma");
|
||||
cs.endText();
|
||||
}
|
||||
assertTrue(PdfUtils.hasText(doc, "all", "gamma"), "'all' should find text on page 1");
|
||||
}
|
||||
}
|
||||
|
||||
// --- hasImagesOnPage with image ---
|
||||
@Test
|
||||
@DisplayName("hasTextOnPage: true if page contains phrase, else false")
|
||||
void hasTextOnPage_shouldReturnTrueOnlyForPagesWithPhrase() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("needle");
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasTextOnPage(p1, "needle"));
|
||||
assertTrue(!PdfUtils.hasTextOnPage(p2, "needle"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasImagesOnPage_withImage_returnsTrue() throws IOException {
|
||||
@DisplayName("hasImages: detects images on selected pages and 'all'")
|
||||
void hasImages_shouldDetectImages_onSelectedPages() throws Exception {
|
||||
// Case 1: Page 1 without image (but resources set) -> false
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
p1.setResources(new PDResources());
|
||||
p2.setResources(new PDResources());
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
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);
|
||||
// Image only on page 2
|
||||
BufferedImage bi = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = bi.createGraphics();
|
||||
g.setColor(Color.GREEN);
|
||||
g.fillRect(0, 0, 20, 20);
|
||||
g.dispose();
|
||||
|
||||
assertTrue(PdfUtils.hasImagesOnPage(page));
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
|
||||
cs.drawImage(ximg, 50, 700, 20, 20);
|
||||
}
|
||||
|
||||
assertTrue(!PdfUtils.hasImages(doc, "1"), "Page 1 should have no image");
|
||||
}
|
||||
|
||||
// Case 2: Page 2 with image -> true
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
p1.setResources(new PDResources());
|
||||
p2.setResources(new PDResources());
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
BufferedImage bi = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = bi.createGraphics();
|
||||
g.setColor(Color.BLUE);
|
||||
g.fillRect(0, 0, 20, 20);
|
||||
g.dispose();
|
||||
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
|
||||
cs.drawImage(ximg, 50, 700, 20, 20);
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasImages(doc, "2"), "Page 2 should have an image");
|
||||
}
|
||||
|
||||
// Case 3: 'all' detects image
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p = new PDPage(PDRectangle.A4);
|
||||
p.setResources(new PDResources());
|
||||
doc.addPage(p);
|
||||
|
||||
BufferedImage bi = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p, AppendMode.APPEND, true, true)) {
|
||||
cs.drawImage(ximg, 20, 730, 10, 10);
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasImages(doc, "all"), "'all' should detect the image");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hasImagesOnPage: true if page contains an image, else false")
|
||||
void hasImagesOnPage_shouldReturnTrueOnlyForPagesWithImage() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
p1.setResources(new PDResources());
|
||||
p2.setResources(new PDResources());
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
BufferedImage bi = new BufferedImage(12, 12, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = bi.createGraphics();
|
||||
g.setColor(Color.RED);
|
||||
g.fillRect(0, 0, 12, 12);
|
||||
g.dispose();
|
||||
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.drawImage(ximg, 40, 720, 12, 12);
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasImagesOnPage(p1));
|
||||
assertTrue(!PdfUtils.hasImagesOnPage(p2));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertFromPdf: singleImage=true with JPG -> no alpha, white background")
|
||||
void convertFromPdf_singleImageJpg_noAlphaWhiteBackground() throws Exception {
|
||||
// small 1-page PDF
|
||||
byte[] pdfBytes;
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
pdfBytes = baos.toByteArray();
|
||||
}
|
||||
|
||||
byte[] jpgBytes =
|
||||
PdfUtils.convertFromPdf(
|
||||
factory, pdfBytes, "jpg", ImageType.RGB, true, 72, "sample.pdf", false);
|
||||
|
||||
BufferedImage img = ImageIO.read(new ByteArrayInputStream(jpgBytes));
|
||||
assertNotNull(img, "JPG should be readable");
|
||||
|
||||
ColorModel cm = img.getColorModel();
|
||||
assertFalse(cm.hasAlpha(), "JPG output should have no alpha channel");
|
||||
|
||||
// JPG background should be white (approximate check)
|
||||
int rgb = img.getRGB(img.getWidth() / 2, img.getHeight() / 2) & 0x00FFFFFF;
|
||||
assertEquals(0xFFFFFF, rgb, "Background pixel should be white");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,119 +2,85 @@ package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ProcessExecutorTest {
|
||||
public 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 processExecutor;
|
||||
|
||||
private ProcessExecutor getExecutor() {
|
||||
return ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF);
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
// Initialize the ProcessExecutor instance
|
||||
processExecutor = ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE);
|
||||
}
|
||||
|
||||
@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() {
|
||||
public void testRunCommandWithOutputHandling() throws IOException, InterruptedException {
|
||||
// Mock the command to execute
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("echo");
|
||||
command.add(null);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> invokeValidateCommand(getExecutor(), command));
|
||||
}
|
||||
command.add("java");
|
||||
command.add("-version");
|
||||
|
||||
@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();
|
||||
// Execute the command
|
||||
ProcessExecutor.ProcessExecutorResult result =
|
||||
executor.new ProcessExecutorResult(0, "success");
|
||||
assertEquals(0, result.getRc());
|
||||
assertEquals("success", result.getMessages());
|
||||
processExecutor.runCommandWithOutputHandling(command);
|
||||
|
||||
result.setRc(1);
|
||||
result.setMessages("error");
|
||||
assertEquals(1, result.getRc());
|
||||
assertEquals("error", result.getMessages());
|
||||
// Check the exit code and output messages
|
||||
assertEquals(0, result.getRc());
|
||||
assertNotNull(result.getMessages()); // Check if messages are not null
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRunCommandWithOutputHandling_Error() {
|
||||
// Test with a command that will fail to execute (non-existent command)
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("nonexistent-command-that-does-not-exist");
|
||||
|
||||
// Execute the command and expect an IOException (command not found)
|
||||
assertThrows(
|
||||
IOException.class, () -> processExecutor.runCommandWithOutputHandling(command));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRunCommandWithOutputHandling_PathTraversal() {
|
||||
// Test that path traversal is blocked
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("../../../etc/passwd");
|
||||
|
||||
// Execute the command and expect an IllegalArgumentException
|
||||
IllegalArgumentException thrown =
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> processExecutor.runCommandWithOutputHandling(command));
|
||||
|
||||
// Check the exception message
|
||||
String errorMessage = thrown.getMessage();
|
||||
assertTrue(
|
||||
errorMessage.contains("path traversal"),
|
||||
"Unexpected error message: " + errorMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRunCommandWithOutputHandling_NullByte() {
|
||||
// Test that null bytes are blocked
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("test\0command");
|
||||
|
||||
// Execute the command and expect an IllegalArgumentException
|
||||
IllegalArgumentException thrown =
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> processExecutor.runCommandWithOutputHandling(command));
|
||||
|
||||
// Check the exception message
|
||||
String errorMessage = thrown.getMessage();
|
||||
assertTrue(
|
||||
errorMessage.contains("invalid characters"),
|
||||
"Unexpected error message: " + errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +1,70 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PropertyConfigsTest {
|
||||
public class PropertyConfigsTest {
|
||||
|
||||
private static final String TEST_KEY = "stirling.test.property.key";
|
||||
private static final String TEST_KEY_2 = "stirling.test.property.key2";
|
||||
@Test
|
||||
public void testGetBooleanValue_WithKeys() {
|
||||
// Define keys and default value
|
||||
List<String> keys = Arrays.asList("test.key1", "test.key2", "test.key3");
|
||||
boolean defaultValue = false;
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
System.clearProperty(TEST_KEY);
|
||||
System.clearProperty(TEST_KEY_2);
|
||||
// Set property for one of the keys
|
||||
System.setProperty("test.key2", "true");
|
||||
|
||||
// Call the method under test
|
||||
boolean result = PropertyConfigs.getBooleanValue(keys, defaultValue);
|
||||
|
||||
// Verify the result
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetBooleanValue_singleKey_fromSystemProperty() {
|
||||
System.setProperty(TEST_KEY, "true");
|
||||
assertTrue(PropertyConfigs.getBooleanValue(TEST_KEY, false));
|
||||
public void testGetStringValue_WithKeys() {
|
||||
// Define keys and default value
|
||||
List<String> keys = Arrays.asList("test.key1", "test.key2", "test.key3");
|
||||
String defaultValue = "default";
|
||||
|
||||
// Set property for one of the keys
|
||||
System.setProperty("test.key2", "value");
|
||||
|
||||
// Call the method under test
|
||||
String result = PropertyConfigs.getStringValue(keys, defaultValue);
|
||||
|
||||
// Verify the result
|
||||
assertEquals("value", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetBooleanValue_singleKey_defaultWhenMissing() {
|
||||
assertFalse(PropertyConfigs.getBooleanValue(TEST_KEY, false));
|
||||
assertTrue(PropertyConfigs.getBooleanValue(TEST_KEY, true));
|
||||
public void testGetBooleanValue_WithKey() {
|
||||
// Define key and default value
|
||||
String key = "test.key";
|
||||
boolean defaultValue = true;
|
||||
|
||||
// Call the method under test
|
||||
boolean result = PropertyConfigs.getBooleanValue(key, defaultValue);
|
||||
|
||||
// Verify the result
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetBooleanValue_singleKey_falseValue() {
|
||||
System.setProperty(TEST_KEY, "false");
|
||||
assertFalse(PropertyConfigs.getBooleanValue(TEST_KEY, true));
|
||||
}
|
||||
public void testGetStringValue_WithKey() {
|
||||
// Define key and default value
|
||||
String key = "test.key";
|
||||
String defaultValue = "default";
|
||||
|
||||
@Test
|
||||
void testGetStringValue_singleKey_fromSystemProperty() {
|
||||
System.setProperty(TEST_KEY, "hello");
|
||||
assertEquals("hello", PropertyConfigs.getStringValue(TEST_KEY, "default"));
|
||||
}
|
||||
// Call the method under test
|
||||
String result = PropertyConfigs.getStringValue(key, defaultValue);
|
||||
|
||||
@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));
|
||||
// Verify the result
|
||||
assertEquals("default", result);
|
||||
}
|
||||
}
|
||||
|
||||
-96
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -3,162 +3,338 @@ package stirling.software.common.util;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class RequestUriUtilsTest {
|
||||
|
||||
// --- isStaticResource tests ---
|
||||
public class RequestUriUtilsTest {
|
||||
|
||||
@Test
|
||||
void testIsStaticResource_nullUri() {
|
||||
assertFalse(RequestUriUtils.isStaticResource(null));
|
||||
void testIsStaticResource() {
|
||||
// Test static resources without context path
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/css/styles.css"), "CSS files should be static");
|
||||
assertTrue(RequestUriUtils.isStaticResource("/js/script.js"), "JS files should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/images/logo.png"),
|
||||
"Image files should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/public/index.html"),
|
||||
"Public files should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/pdfjs/pdf.worker.js"),
|
||||
"PDF.js files should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/pdfium/pdfium.wasm"),
|
||||
"PDFium wasm should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/api/v1/info/status"),
|
||||
"API status should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/some-path/icon.svg"),
|
||||
"SVG files should be static");
|
||||
assertTrue(RequestUriUtils.isStaticResource("/login"), "Login page should be static");
|
||||
assertTrue(RequestUriUtils.isStaticResource("/error"), "Error page should be static");
|
||||
|
||||
// Test non-static resources
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource("/api/v1/users"),
|
||||
"API users should not be static");
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource("/api/v1/orders"),
|
||||
"API orders should not be static");
|
||||
assertFalse(RequestUriUtils.isStaticResource("/"), "Root path should not be static");
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource("/register"),
|
||||
"Register page should not be static");
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource("/api/v1/products"),
|
||||
"API products should not be static");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsStaticResource_cssDirectory() {
|
||||
assertTrue(RequestUriUtils.isStaticResource("/css/style.css"));
|
||||
void testIsFrontendRoute() {
|
||||
assertTrue(
|
||||
RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
|
||||
assertTrue(
|
||||
RequestUriUtils.isFrontendRoute("", "/app/dashboard"),
|
||||
"React routes without extensions should be frontend routes");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/api/v1/users"),
|
||||
"API routes should not be frontend routes");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/register"),
|
||||
"Register should not be treated as a frontend route");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/pipeline/jobs"),
|
||||
"Pipeline should not be treated as a frontend route");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/files/download"),
|
||||
"Files path should not be treated as a frontend route");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsStaticResource_jsDirectory() {
|
||||
assertTrue(RequestUriUtils.isStaticResource("/js/app.js"));
|
||||
void testIsStaticResourceWithContextPath() {
|
||||
String contextPath = "/myapp";
|
||||
|
||||
// Test static resources with context path
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(contextPath, contextPath + "/css/styles.css"),
|
||||
"CSS with context path should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(contextPath, contextPath + "/js/script.js"),
|
||||
"JS with context path should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(contextPath, contextPath + "/images/logo.png"),
|
||||
"Images with context path should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(contextPath, contextPath + "/login"),
|
||||
"Login with context path should be static");
|
||||
|
||||
// Test non-static resources with context path
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource(contextPath, contextPath + "/api/v1/users"),
|
||||
"API users with context path should not be static");
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource(contextPath, "/"),
|
||||
"Root path with context path should not be static");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"robots.txt",
|
||||
"/favicon.ico",
|
||||
"/icon.svg",
|
||||
"/image.png",
|
||||
"/locales/en/translation.toml",
|
||||
"/site.webmanifest",
|
||||
"/app/logo.svg",
|
||||
"/downloads/document.png",
|
||||
"/assets/brand.ico",
|
||||
"/any/path/with/image.svg",
|
||||
"/deep/nested/folder/icon.png",
|
||||
"/pdfium/pdfium.wasm"
|
||||
})
|
||||
void testIsStaticResourceWithFileExtensions(String path) {
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(path),
|
||||
"Files with specific extensions should be static regardless of path");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsStaticResource_imagesDirectory() {
|
||||
assertTrue(RequestUriUtils.isStaticResource("/images/logo.png"));
|
||||
void testIsTrackableResource() {
|
||||
// Test non-trackable resources (returns false)
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/js/script.js"),
|
||||
"JS files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/v1/api-docs"),
|
||||
"API docs should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("robots.txt"),
|
||||
"robots.txt should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/images/logo.png"),
|
||||
"Images should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/styles.css"),
|
||||
"CSS files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/script.js.map"),
|
||||
"Map files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/icon.svg"),
|
||||
"SVG files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/popularity.txt"),
|
||||
"Popularity file should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/script.js"),
|
||||
"JS files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/pdfium/pdfium.wasm"),
|
||||
"PDFium wasm should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/swagger/index.html"),
|
||||
"Swagger files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/api/v1/info/status"),
|
||||
"API info should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/site.webmanifest"),
|
||||
"Webmanifest should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/fonts/font.woff"),
|
||||
"Fonts should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/pdfjs/viewer.js"),
|
||||
"PDF.js files should not be trackable");
|
||||
|
||||
// Test trackable resources (returns true)
|
||||
assertTrue(RequestUriUtils.isTrackableResource("/login"), "Login page should be trackable");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource("/register"),
|
||||
"Register page should be trackable");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource("/api/v1/users"),
|
||||
"API users should be trackable");
|
||||
assertTrue(RequestUriUtils.isTrackableResource("/"), "Root path should be trackable");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource("/some-other-path"),
|
||||
"Other paths should be trackable");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsStaticResource_robotsTxt() {
|
||||
assertTrue(RequestUriUtils.isStaticResource("/robots.txt"));
|
||||
void testIsTrackableResourceWithContextPath() {
|
||||
String contextPath = "/myapp";
|
||||
|
||||
// Test with context path
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/js/script.js"),
|
||||
"JS files should not be trackable with context path");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/login"),
|
||||
"Login page should be trackable with context path");
|
||||
|
||||
// Additional tests with context path
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/fonts/custom.woff"),
|
||||
"Font files should not be trackable with context path");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/images/header.png"),
|
||||
"Images should not be trackable with context path");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/swagger/ui.html"),
|
||||
"Swagger UI should not be trackable with context path");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/account/profile"),
|
||||
"Account page should be trackable with context path");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/pdf/view"),
|
||||
"PDF view page should be trackable with context path");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"/js/util.js",
|
||||
"/v1/api-docs/swagger.json",
|
||||
"/robots.txt",
|
||||
"/images/header/logo.png",
|
||||
"/styles/theme.css",
|
||||
"/build/app.js.map",
|
||||
"/assets/icon.svg",
|
||||
"/data/popularity.txt",
|
||||
"/bundle.js",
|
||||
"/api/swagger-ui.html",
|
||||
"/api/v1/info/health",
|
||||
"/site.webmanifest",
|
||||
"/fonts/roboto.woff",
|
||||
"/pdfjs/viewer.js",
|
||||
"/pdfium/pdfium.wasm"
|
||||
})
|
||||
void testNonTrackableResources(String path) {
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource(path),
|
||||
"Resources matching patterns should not be trackable: " + path);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"/",
|
||||
"/home",
|
||||
"/login",
|
||||
"/register",
|
||||
"/pdf/merge",
|
||||
"/pdf/split",
|
||||
"/api/v1/users/1",
|
||||
"/api/v1/documents/process",
|
||||
"/settings",
|
||||
"/account/profile",
|
||||
"/dashboard",
|
||||
"/help",
|
||||
"/about"
|
||||
})
|
||||
void testTrackableResources(String path) {
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource(path),
|
||||
"App routes should be trackable: " + path);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsStaticResource_faviconIco() {
|
||||
assertTrue(RequestUriUtils.isStaticResource("/favicon.ico"));
|
||||
void testEdgeCases() {
|
||||
// Test with empty strings
|
||||
assertFalse(RequestUriUtils.isStaticResource("", ""), "Empty path should not be static");
|
||||
assertTrue(RequestUriUtils.isTrackableResource("", ""), "Empty path should be trackable");
|
||||
|
||||
// Test with null-like behavior (would actually throw NPE in real code)
|
||||
// These are not actual null tests but shows handling of odd cases
|
||||
assertFalse(RequestUriUtils.isStaticResource("null"), "String 'null' should not be static");
|
||||
|
||||
// Test String "null" as a path
|
||||
boolean isTrackable = RequestUriUtils.isTrackableResource("null");
|
||||
assertTrue(isTrackable, "String 'null' should be trackable");
|
||||
|
||||
// Mixed case extensions test - note that Java's endsWith() is case-sensitive
|
||||
// We'll check actual behavior and document it rather than asserting
|
||||
|
||||
// Always test the lowercase versions which should definitely work
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/logo.png"), "PNG (lowercase) should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/icon.svg"), "SVG (lowercase) should be static");
|
||||
|
||||
// Path with query parameters
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource("/api/users?page=1"),
|
||||
"Path with query params should respect base path");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/images/logo.png?v=123"),
|
||||
"Static resource with query params should still be static");
|
||||
|
||||
// Paths with fragments
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/css/styles.css#section1"),
|
||||
"CSS with fragment should be static");
|
||||
|
||||
// Multiple dots in filename
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/js/jquery.min.js"),
|
||||
"JS with multiple dots should be static");
|
||||
|
||||
// Special characters in path
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/images/user's-photo.png"),
|
||||
"Path with special chars should be handled correctly");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsStaticResource_loginPath() {
|
||||
assertTrue(RequestUriUtils.isStaticResource("/login"));
|
||||
}
|
||||
void testComplexPaths() {
|
||||
// Test complex static resource paths
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/css/theme/dark/styles.css"),
|
||||
"Nested CSS should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/fonts/open-sans/bold/font.woff"),
|
||||
"Nested font should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/js/vendor/jquery/3.5.1/jquery.min.js"),
|
||||
"Versioned JS should be static");
|
||||
|
||||
@Test
|
||||
void testIsStaticResource_errorPath() {
|
||||
assertTrue(RequestUriUtils.isStaticResource("/error"));
|
||||
}
|
||||
// Test complex paths with context
|
||||
String contextPath = "/app";
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(
|
||||
contextPath, contextPath + "/css/theme/dark/styles.css"),
|
||||
"Nested CSS with context should be static");
|
||||
|
||||
@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"));
|
||||
// Test boundary cases for isTrackableResource
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/js-framework/components"),
|
||||
"Path starting with js- should not be treated as JS resource");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/fonts-selection"),
|
||||
"Path starting with fonts- should not be treated as font resource");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 +1,276 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.ServerSocket;
|
||||
|
||||
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;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UrlUtilsTest {
|
||||
|
||||
@Mock private HttpServletRequest request;
|
||||
|
||||
@Test
|
||||
void testGetOrigin_standardRequest() {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
void testGetOrigin() {
|
||||
// Arrange
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("/myapp");
|
||||
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"http://localhost:8080/myapp", origin, "Origin URL should be correctly formatted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOriginWithHttps() {
|
||||
// Arrange
|
||||
when(request.getScheme()).thenReturn("https");
|
||||
when(request.getServerName()).thenReturn("example.com");
|
||||
when(request.getServerPort()).thenReturn(443);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"https://example.com:443",
|
||||
origin,
|
||||
"HTTPS origin URL should be correctly formatted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOriginWithEmptyContextPath() {
|
||||
// Arrange
|
||||
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));
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"http://localhost:8080",
|
||||
origin,
|
||||
"Origin URL with empty context path should be correct");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOrigin_httpsWithContextPath() {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
void testGetOriginWithSpecialCharacters() {
|
||||
// Arrange - Test with server name containing special characters
|
||||
when(request.getScheme()).thenReturn("https");
|
||||
when(request.getServerName()).thenReturn("example.com");
|
||||
when(request.getServerPort()).thenReturn(443);
|
||||
when(request.getContextPath()).thenReturn("/myapp");
|
||||
when(request.getServerName()).thenReturn("internal-server.example-domain.com");
|
||||
when(request.getServerPort()).thenReturn(8443);
|
||||
when(request.getContextPath()).thenReturn("/app-v1.2");
|
||||
|
||||
assertEquals("https://example.com:443/myapp", UrlUtils.getOrigin(request));
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"https://internal-server.example-domain.com:8443/app-v1.2",
|
||||
origin,
|
||||
"Origin URL with special characters should be correctly formatted");
|
||||
}
|
||||
|
||||
@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);
|
||||
void testGetOriginWithIPv4Address() {
|
||||
// Arrange
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("192.168.1.1");
|
||||
when(request.getServerPort()).thenReturn(9090);
|
||||
when(request.getServerName()).thenReturn("192.168.1.100");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("/app");
|
||||
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"http://192.168.1.100:8080/app",
|
||||
origin,
|
||||
"Origin URL with IPv4 address should be correctly formatted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOriginWithNonStandardPort() {
|
||||
// Arrange
|
||||
when(request.getScheme()).thenReturn("https");
|
||||
when(request.getServerName()).thenReturn("example.org");
|
||||
when(request.getServerPort()).thenReturn(8443);
|
||||
when(request.getContextPath()).thenReturn("/api");
|
||||
|
||||
assertEquals("http://192.168.1.1:9090/api", UrlUtils.getOrigin(request));
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"https://example.org:8443/api",
|
||||
origin,
|
||||
"Origin URL with non-standard port should be correctly formatted");
|
||||
}
|
||||
|
||||
@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("");
|
||||
void testIsPortAvailable() {
|
||||
// We'll use a real server socket for this test
|
||||
ServerSocket socket = null;
|
||||
int port = 12345; // Choose a port unlikely to be in use
|
||||
|
||||
assertEquals("http://example.com:80", UrlUtils.getOrigin(request));
|
||||
try {
|
||||
// First check the port is available
|
||||
boolean initialAvailability = UrlUtils.isPortAvailable(port);
|
||||
|
||||
// Then occupy the port
|
||||
socket = new ServerSocket(port);
|
||||
|
||||
// Now check the port is no longer available
|
||||
boolean afterSocketCreation = UrlUtils.isPortAvailable(port);
|
||||
|
||||
// Assert
|
||||
assertTrue(initialAvailability, "Port should be available initially");
|
||||
assertFalse(
|
||||
afterSocketCreation, "Port should not be available after socket is created");
|
||||
|
||||
} catch (IOException e) {
|
||||
// This might happen if the port is already in use by another process
|
||||
// We'll just verify the behavior of isPortAvailable matches what we expect
|
||||
assertFalse(
|
||||
UrlUtils.isPortAvailable(port),
|
||||
"Port should not be available if exception is thrown");
|
||||
} finally {
|
||||
if (socket != null && !socket.isClosed()) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException e) {
|
||||
// Ignore cleanup exceptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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("");
|
||||
void testFindAvailablePort() {
|
||||
// We'll create a socket on a port and ensure findAvailablePort returns a different port
|
||||
ServerSocket socket = null;
|
||||
int startPort = 12346; // Choose a port unlikely to be in use
|
||||
|
||||
assertEquals("https://app.example.com:443", UrlUtils.getOrigin(request));
|
||||
try {
|
||||
// Occupy the start port
|
||||
socket = new ServerSocket(startPort);
|
||||
|
||||
// Find an available port
|
||||
String availablePort = UrlUtils.findAvailablePort(startPort);
|
||||
|
||||
// Assert the returned port is not the occupied one
|
||||
assertNotEquals(
|
||||
String.valueOf(startPort),
|
||||
availablePort,
|
||||
"findAvailablePort should not return an occupied port");
|
||||
|
||||
// Verify the returned port is actually available
|
||||
int portNumber = Integer.parseInt(availablePort);
|
||||
|
||||
// Close our test socket before checking the found port
|
||||
socket.close();
|
||||
socket = null;
|
||||
|
||||
// The port should now be available
|
||||
assertTrue(
|
||||
UrlUtils.isPortAvailable(portNumber),
|
||||
"The port returned by findAvailablePort should be available");
|
||||
|
||||
} catch (IOException e) {
|
||||
// If we can't create the socket, skip this assertion
|
||||
} finally {
|
||||
if (socket != null && !socket.isClosed()) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException e) {
|
||||
// Ignore cleanup exceptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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");
|
||||
void testFindAvailablePortWithAvailableStartPort() {
|
||||
// Find an available port without occupying any
|
||||
int startPort = 23456; // Choose a different unlikely-to-be-used port
|
||||
|
||||
assertEquals("http://host:3000/a/b/c", UrlUtils.getOrigin(request));
|
||||
// Make sure the port is available first
|
||||
if (UrlUtils.isPortAvailable(startPort)) {
|
||||
// Find an available port
|
||||
String availablePort = UrlUtils.findAvailablePort(startPort);
|
||||
|
||||
// Assert the returned port is the start port since it's available
|
||||
assertEquals(
|
||||
String.valueOf(startPort),
|
||||
availablePort,
|
||||
"findAvailablePort should return the start port if it's available");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindAvailablePort_returnsStringOfPort() {
|
||||
String port = UrlUtils.findAvailablePort(49152);
|
||||
assertNotNull(port);
|
||||
int portNum = Integer.parseInt(port);
|
||||
assertTrue(portNum >= 49152);
|
||||
void testFindAvailablePortWithSequentialUsedPorts() {
|
||||
// This test checks that findAvailablePort correctly skips multiple occupied ports
|
||||
ServerSocket socket1 = null;
|
||||
ServerSocket socket2 = null;
|
||||
int startPort = 34567; // Another unlikely-to-be-used port
|
||||
|
||||
try {
|
||||
// First verify the port is available
|
||||
if (!UrlUtils.isPortAvailable(startPort)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Occupy two sequential ports
|
||||
socket1 = new ServerSocket(startPort);
|
||||
socket2 = new ServerSocket(startPort + 1);
|
||||
|
||||
// Find an available port starting from our occupied range
|
||||
String availablePort = UrlUtils.findAvailablePort(startPort);
|
||||
int foundPort = Integer.parseInt(availablePort);
|
||||
|
||||
// Should have skipped the two occupied ports
|
||||
assertTrue(
|
||||
foundPort >= startPort + 2,
|
||||
"findAvailablePort should skip sequential occupied ports");
|
||||
|
||||
// Verify the found port is actually available
|
||||
try (ServerSocket testSocket = new ServerSocket(foundPort)) {
|
||||
assertTrue(testSocket.isBound(), "The found port should be bindable");
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
// Skip test if we encounter IO exceptions
|
||||
} finally {
|
||||
// Clean up resources
|
||||
try {
|
||||
if (socket1 != null && !socket1.isClosed()) socket1.close();
|
||||
if (socket2 != null && !socket2.isClosed()) socket2.close();
|
||||
} catch (IOException e) {
|
||||
// Ignore cleanup exceptions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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));
|
||||
void testIsPortAvailableWithPrivilegedPorts() {
|
||||
// Skip tests for privileged ports as they typically require root access
|
||||
// and results are environment-dependent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -4,91 +4,112 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
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;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
class WebResponseUtilsTest {
|
||||
public class WebResponseUtilsTest {
|
||||
|
||||
@Test
|
||||
void testBytesToWebResponse_defaultMediaType() throws IOException {
|
||||
byte[] data = "test content".getBytes(StandardCharsets.UTF_8);
|
||||
ResponseEntity<byte[]> response = WebResponseUtils.bytesToWebResponse(data, "output.pdf");
|
||||
public void testBoasToWebResponse() {
|
||||
try {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
baos.write("Sample PDF content".getBytes());
|
||||
String docName = "sample.pdf";
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals(MediaType.APPLICATION_PDF, response.getHeaders().getContentType());
|
||||
assertEquals(data.length, response.getHeaders().getContentLength());
|
||||
assertArrayEquals(data, response.getBody());
|
||||
ResponseEntity<byte[]> responseEntity =
|
||||
WebResponseUtils.baosToWebResponse(baos, docName);
|
||||
|
||||
assertNotNull(responseEntity);
|
||||
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
|
||||
assertNotNull(responseEntity.getBody());
|
||||
|
||||
HttpHeaders headers = responseEntity.getHeaders();
|
||||
assertNotNull(headers);
|
||||
assertEquals(MediaType.APPLICATION_PDF, headers.getContentType());
|
||||
assertNotNull(headers.getContentDisposition());
|
||||
// assertEquals("attachment; filename=\"sample.pdf\"",
|
||||
// headers.getContentDisposition().toString());
|
||||
|
||||
} catch (IOException e) {
|
||||
fail("Exception thrown: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
public void testMultiPartFileToWebResponse() {
|
||||
try {
|
||||
byte[] fileContent = "Sample file content".getBytes();
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"file", "sample.txt", MediaType.TEXT_PLAIN_VALUE, fileContent);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals(MediaType.APPLICATION_OCTET_STREAM, response.getHeaders().getContentType());
|
||||
assertArrayEquals(data, response.getBody());
|
||||
ResponseEntity<byte[]> responseEntity =
|
||||
WebResponseUtils.multiPartFileToWebResponse(file);
|
||||
|
||||
assertNotNull(responseEntity);
|
||||
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
|
||||
assertNotNull(responseEntity.getBody());
|
||||
|
||||
HttpHeaders headers = responseEntity.getHeaders();
|
||||
assertNotNull(headers);
|
||||
assertEquals(MediaType.TEXT_PLAIN, headers.getContentType());
|
||||
assertNotNull(headers.getContentDisposition());
|
||||
|
||||
} catch (IOException e) {
|
||||
fail("Exception thrown: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBaosToWebResponse() throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
baos.write("baos content".getBytes(StandardCharsets.UTF_8));
|
||||
public void testBytesToWebResponse() {
|
||||
try {
|
||||
byte[] bytes = "Sample bytes".getBytes();
|
||||
String docName = "sample.txt";
|
||||
MediaType mediaType = MediaType.TEXT_PLAIN;
|
||||
|
||||
ResponseEntity<byte[]> response = WebResponseUtils.baosToWebResponse(baos, "doc.pdf");
|
||||
ResponseEntity<byte[]> responseEntity =
|
||||
WebResponseUtils.bytesToWebResponse(bytes, docName, mediaType);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
assertEquals("baos content", new String(response.getBody(), StandardCharsets.UTF_8));
|
||||
assertNotNull(responseEntity);
|
||||
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
|
||||
assertNotNull(responseEntity.getBody());
|
||||
|
||||
HttpHeaders headers = responseEntity.getHeaders();
|
||||
assertNotNull(headers);
|
||||
assertEquals(MediaType.TEXT_PLAIN, headers.getContentType());
|
||||
assertNotNull(headers.getContentDisposition());
|
||||
|
||||
} catch (IOException e) {
|
||||
fail("Exception thrown: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBaosToWebResponse_withMediaType() throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
baos.write("data".getBytes(StandardCharsets.UTF_8));
|
||||
public void testPdfDocToWebResponse() {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
document.addPage(new org.apache.pdfbox.pdmodel.PDPage());
|
||||
String docName = "sample.pdf";
|
||||
|
||||
ResponseEntity<byte[]> response =
|
||||
WebResponseUtils.baosToWebResponse(baos, "doc.html", MediaType.TEXT_HTML);
|
||||
ResponseEntity<byte[]> responseEntity =
|
||||
WebResponseUtils.pdfDocToWebResponse(document, docName);
|
||||
|
||||
assertEquals(MediaType.TEXT_HTML, response.getHeaders().getContentType());
|
||||
}
|
||||
assertNotNull(responseEntity);
|
||||
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
|
||||
assertNotNull(responseEntity.getBody());
|
||||
|
||||
@Test
|
||||
void testBytesToWebResponse_contentDispositionHeader() throws IOException {
|
||||
byte[] data = "test".getBytes(StandardCharsets.UTF_8);
|
||||
ResponseEntity<byte[]> response = WebResponseUtils.bytesToWebResponse(data, "my file.pdf");
|
||||
HttpHeaders headers = responseEntity.getHeaders();
|
||||
assertNotNull(headers);
|
||||
assertEquals(MediaType.APPLICATION_PDF, headers.getContentType());
|
||||
assertNotNull(headers.getContentDisposition());
|
||||
|
||||
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());
|
||||
} catch (IOException e) {
|
||||
fail("Exception thrown: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,11 @@ configurations {
|
||||
spotless {
|
||||
java {
|
||||
target 'src/**/java/**/*.java'
|
||||
targetExclude 'src/main/resources/static/**', 'src/main/java/org/apache/**'
|
||||
targetExclude 'src/main/resources/static/**'
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
toggleOffOn()
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
@@ -46,7 +47,6 @@ dependencies {
|
||||
implementation project(':common')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jetty'
|
||||
implementation 'org.eclipse.jetty.http2:jetty-http2-server'
|
||||
implementation 'org.eclipse.jetty:jetty-alpn-java-server'
|
||||
implementation ('org.telegram:telegrambots:6.9.7.1') {
|
||||
// Grizzly server + Jersey JAX-RS stack: only used for webhook mode;
|
||||
// Stirling-PDF uses long-polling mode so these are dead weight (~3 MB)
|
||||
@@ -82,7 +82,7 @@ dependencies {
|
||||
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
|
||||
implementation 'javax.xml.bind:jaxb-api:2.3.1'
|
||||
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
|
||||
implementation 'com.sun.xml.bind:jaxb-core:4.0.6'
|
||||
implementation 'com.sun.xml.bind:jaxb-core:2.3.0.1'
|
||||
implementation 'org.apache.poi:poi-ooxml:5.5.1'
|
||||
|
||||
// https://mvnrepository.com/artifact/technology.tabula/tabula
|
||||
@@ -91,7 +91,7 @@ dependencies {
|
||||
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
|
||||
exclude group: 'com.google.code.gson', module: 'gson'
|
||||
}
|
||||
// CVE-2022-25647: Explicit gson 2.13.2 to prevent unsafe deserialization (tabula would pull 2.8.7)
|
||||
// CVE-2022-25647: Explicit gson 2.8.9 to prevent unsafe deserialization (tabula would pull 2.8.7)
|
||||
implementation 'com.google.code.gson:gson:2.13.2'
|
||||
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
|
||||
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
|
||||
@@ -100,8 +100,6 @@ dependencies {
|
||||
// Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
|
||||
// Replaces batik-all which included unused codec, svggen, transcoder, script modules
|
||||
implementation 'org.apache.xmlgraphics:batik-bridge:1.19'
|
||||
// Required by TwelveMonkeys imageio-batik SPI (SVGImageReaderSpi) during ImageIO init
|
||||
runtimeOnly 'org.apache.xmlgraphics:batik-transcoder:1.19'
|
||||
|
||||
// PDFBox Graphics2D bridge for Batik SVG to PDF conversion
|
||||
implementation 'de.rototor.pdfbox:graphics2d:3.0.5'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user