Compare commits

...
Author SHA1 Message Date
Frooodle 23a3ef3080 merge main 2026-06-24 10:49:51 +01:00
LudyandGitHub 60fff188a6 fix(team): hide users already assigned to the selected team (#6760)
# Description of Changes

# Description of Changes

- What was changed
- Filtered the "Add Member to Team" user picker so users who are already
members of the selected target team are no longer shown.
  - Applied the same filtering in both team management entry points:
    - `TeamsSection`
    - `TeamDetailsSection`
- Kept users in other teams visible, so they can still be moved into the
selected team.

- Why the change was made
- The modal was showing users who were already part of the target team,
which made the action misleading and allowed redundant selection.
- Hiding already-assigned users keeps the UI aligned with the actual
action: adding new members to the team

before:

<img width="442" height="425" alt="image"
src="https://github.com/user-attachments/assets/9faf991f-a7d3-4a48-91cd-f47730decde8"
/>

after:

<img width="430" height="382" alt="image"
src="https://github.com/user-attachments/assets/f8291172-feb6-4d8d-b536-eebf752b764b"
/>



---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-24 08:15:08 +00:00
Anthony Stirling 5c47a3fc87 Bump version to 2.13.2 2026-06-23 20:54:12 +01:00
Anthony Stirling 1eae354c35 Drop push-docker BASE_VERSION override to use Dockerfile default 2026-06-23 20:34:52 +01:00
Anthony Stirling f113b4f4b4 Pin release docker base image to 1.0.2 to remove ffmpeg binary 2026-06-23 20:16:23 +01:00
James BruntonandGitHub 41181c9da1 Redesign tool config types to avoid any typing (#6582)
# Description of Changes
Fixes one of the main causes of `any` typing left in tools, the way that
we register tool parameters in the registry. Currently, it just accepts
tool params via `any`, but instead we can explicitly change them to
`Record<string, unknown)`, so on the way back out they can more safely
be cast back to their correct type when known.

One consequence of this is that I had to redesign the way we
special-case the Convert tool, which previously was a different shape
than all the other param types. Now it's just got optional parameters on
it, which isn't quite as type-safe as before, but it does mean all tools
are a consistent shape now, which I think is worth the tradeoff.
2026-06-23 15:44:52 +00:00
EthanHealy01andGitHub 8e485801c9 change policies ui (#6683)
• Removed colors from policies to make them look more professional.
• upgraded to enterprise link to contact us.
• Hid inactive policies from users (Kept for admin and team lead).
• Closing policies had wrong arrow, made a standard component for chat,
tools and policies header.
2026-06-23 13:57:17 +00:00
EthanHealy01andGitHub 436afa51d7 Always use the modern logo in the SaaS build (#6775)
## What

Make the **SaaS** build always use the modern logo, so the classic logo
can no longer appear anywhere in the SaaS app.

This is a minimal, SaaS-only alternative to the full classic-logo
removal PR (~80 files). **OSS (`core`) and proprietary builds are
untouched** — they keep the full modern/classic variant system,
including the admin _Logo Style_ picker.

## How

A single SaaS-layer override shadows the core hook:

- `frontend/editor/src/saas/hooks/useLogoVariant.ts` → returns
`"modern"` unconditionally.

In the SaaS build the `@app/*` alias cascade resolves
`@app/hooks/useLogoVariant` to `src/saas/*` before `src/core/*`, so this
shadows the core implementation (which otherwise resolves the variant
from the stored user preference or the server `logoStyle`).

## Why one file is enough

All logo rendering funnels through `useLogoVariant()`:

- `useLogoAssets()` → favicon, web manifest, apple-touch icon, wordmark,
`logo512`, tooltip logo — consumed by `BrandingAssetManager` (which sets
`<link rel="icon|manifest|apple-touch-icon">`), `Wordmark`, `LogoIcon`,
`Tooltip`.
- `useLogoPath()` → the no-text logo SVGs.
- The login-carousel slides (`buildLoginSlides`) receive the variant
from `AuthLayout`, which calls the hook.

Everything else that references a logo in SaaS already hardcodes
`modern-logo` (`index.html`, the SaaS
`Login`/`Signup`/`AuthCallback`/`OAuthConsent` routes, cloud onboarding,
account/MFA QR logos).

The only hardcoded `classic-logo` reference — the admin _Logo Style_
picker in `AdminGeneralSection` — is **not shipped in SaaS**:
`createSaasConfigNavSections` builds from the core nav sections and
never includes the proprietary admin sections.

`manifest-classic.json` and the classic assets remain in the shared
`public/` folder (served by all builds) but are never referenced in the
SaaS bundle.

## Test plan

- [x] `task frontend:typecheck:saas` — clean
- [x] `eslint` on the new file — clean
2026-06-23 13:03:17 +00:00
Anthony StirlingandGitHub 0a29186ed6 Add JUnit tests to raise coverage across all modules (#6782)
# Description of Changes

AI generated junit tests

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-23 09:27:03 +00:00
James BruntonandGitHub 101502cf4f Upgrade to TypeScript 6 (#6772)
# Description of Changes
TS6 introduced backwards-incompatible changes which affected us a little
bit. Other than that, I don't think it significantly changes things for
us, but we will need to deal with these breaking changes to be able to
upgrade to TS7 (the version written in Go, so dramatically faster), so
I'd rather do the work now before TS7 actually releases.

Main things I've done:
- Removed the use of `baseUrl` in the `tsconfig.json` files 
- Explicitly provide the `node` types where needed
- Explicitly reference the un-referenced but required Google API types
- Dropped the installation of `madge` which we weren't using and wasn't
directly compatible with TS6
- Updated the `i18next` packages for explicit TS6 compatibility
- Explicitly override `tsconfck` to force TS6 compatibility since we
can't upgrade it. We're only using that for `vite-tsconfig-paths` and it
all still seems to work fine, so I don't think this is an issue. I think
we can theoretically drop `vite-tsconfig-paths` when we upgrade to Vite
8 ([because it supports
`paths`](https://v8.vite.dev/guide/features#paths)), but that's a bigger
job than I want to do in this PR
2026-06-23 08:52:32 +00:00
James BruntonandGitHub f2b65f4a77 Make pre-commit scripts more OS-agnostic (#6724)
# Description of Changes
Fix #6723
2026-06-23 08:42:18 +00:00
James BruntonandGitHub 1816bad1ba Unified auth for portal and editor (#6725)
# Description of Changes
Refactor frontend auth to the shared folder and hook it up to both the
portal and editor so they share the same system. Also adds various tasks
to help run the portal, including `task dev:portal` to spawn the portal
with the backend, and `task dev:portal:proxy` to spawn the editor,
portal and backend, and a reverse proxy (at localhost:3000) to allow you
to use both at once to simulate how this will actually be deployed,
allowing you to check whether the seamless transition between the two
actually works.
2026-06-22 16:22:36 +00:00
James BruntonandGitHub 9aee85d55e Wrap first login popup in a form so enter works to change password (#6769)
# Description of Changes

> [!note]
> GitHub absolutely mangles the diff unless you change to ignore
whitespace changes

This page has never had the Enter key bound to the Change Password
button:

<img width="673" height="745" alt="image"
src="https://github.com/user-attachments/assets/7a1b06f0-2945-4270-a795-f799ec556c12"
/>

This PR changes the modal to be properly wrapped in a form so key
commands work correctly on it.
2026-06-22 13:59:28 +00:00
James BruntonandGitHub 72f8705460 Build Rust cache on nightlies (#6768)
# Description of Changes
Rust cache added in #6732 never fired because `main` builds don't
include building the desktop apps. We could build them on `main` builds,
but that's fairly expensive, so just build them on nightlies instead to
warm the cache for any desktop PRs the next day
2026-06-22 13:59:13 +00:00
Reece BrowneandGitHub dffc292888 I18n on portal (#6761)
## Overview

Internationalizes the **developer portal**, which previously had **zero
i18n** — every string was hardcoded across ~118 components. Rather than
stand up a parallel system, this shares the **editor's** existing i18n
setup (same TOML locale format, same Crowdin pipeline), then converts
every portal surface to `react-i18next` and adds a CI guard so coverage
can't regress.

## What's included

### 🔗 Shared i18n core (`@shared/i18n`)
- Extracts the editor's `TomlBackend` (HTTP loader for
`public/locales/{lng}/translation.toml`) and language metadata/helpers
(the 42-language list, RTL set, `LanguageSource` priority, code
normalizers) into `frontend/shared/i18n/`.
- The **editor** now imports and re-exports these from `@shared/i18n` —
its 20+ consumers are unchanged. Its local `tomlBackend.ts` is deleted.
- The **portal** builds its own i18next instance from the shared core,
with **en-US as the source of truth** and the same
`/locales/{lng}/translation.toml` layout.

### 🌍 Full portal coverage
- Every view and component converted to `t()` — all feature areas (home,
pipelines, sources, infrastructure, usage, documents, agent-builder,
editor-admin, policies, users, docs, catalogue, components view) plus
app shell, nav, modals, and the home/domain widgets.
- **1108 keys across ~30 namespaces** in
`portal/public/locales/en-US/translation.toml`, grouped by feature;
shared strings under `[common]`. Plurals use i18next count forms;
dynamic labels (nav, settings sections, status badges) use template keys
against populated tables.
- Data-driven strings (values from `@portal/api/*` mocks, enum/id
values, code samples) are intentionally left untranslated — they're
data, not UI chrome.

### ✅ CI coverage guard
- `portal/scripts/check-i18n.mjs` fails if any static `t("key")` in
portal source is missing from the en-US locale. Wired into
`frontend:check` and `frontend:check:all`, so missed keys break CI. This
mirrors the editor's `missingTranslations` test for the portal, which
has no vitest harness of its own.

## Testing
- `task frontend:check:all` passes locally (typecheck all variants,
lint, format, **portal i18n guard**, builds, tests, storybook).
- Every static `t()` key verified to resolve in the locale (1108 keys /
186 source files); all dynamic key prefixes map to populated tables.
- Runtime sweep of all 12 portal routes shows **no unresolved keys** on
screen; nav labels, plurals, and array-backed copy all render real text.

## Follow-ups (not in this PR)
- **Crowdin** — register `frontend/portal/public/locales/` as a source
so portal strings flow through the same translation pipeline as the
editor (an ops step on the Crowdin side; there's no Crowdin config in
the repo).
- Only `en-US` is populated; other languages will arrive via the
pipeline.
2026-06-22 12:57:12 +00:00
LudyandGitHub c95fb89c63 fix(frontend): correctly display the current user role in the edit dialog (#6758)
# Description of Changes

This PR fixes the role field in the People settings "Edit User" dialog
so the currently assigned role is displayed correctly.

- What was changed
- The edit dialog now uses the actual role identifier from the user data
when preselecting the role.
- The role selection is made more robust by falling back to a valid
default when the backend response does not provide a usable role value.
- The role label shown in the UI is derived consistently from the role
identifier.

- Why the change was made
- The dialog could open with an empty role field even though the user
already had a role assigned.
- This made role editing confusing and could lead to accidental changes.
- The fix keeps the People UI aligned with the backend role data model.

before:

<img width="438" height="465" alt="image"
src="https://github.com/user-attachments/assets/929d8c63-8ae9-436a-b895-e6a746f22458"
/>

after:

<img width="437" height="461" alt="image"
src="https://github.com/user-attachments/assets/942f6787-d678-4555-90a9-bf5c7c5c363d"
/>


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-21 10:25:30 +01:00
956b8000e4 build(deps): bump ws from 8.20.1 to 8.21.0 in /frontend (#6679)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 19:25:27 +01:00
Anthony StirlingandGitHub a3fe15bfd0 Add metrics for numerical count of total PDFs (#6737) 2026-06-19 18:57:03 +01:00
1a770af47c fix create tool in the AI chat (#6673)
AI PDF creation ("create a PDF for me") has been broken since the
Policies backend (#6527) introduced PolicyExecutor as the tool execution
pipeline. PolicyExecutor runs normal single-input tools with a per-file
loop, but generator tools like `create-pdf-from-html-agent` take no
input file and build their output purely from parameters. With zero
input files the loop ran zero times, so the endpoint was never called
and the step silently produced nothing. The chat reported success
("Created Purchase Order") while no document ever appeared.

This adds an `else if (inputFiles.isEmpty())` branch so a generator tool
is called once with an empty file list, matching what the multi-input
branch already does for an empty input. Two files changed: the
one-line-ish fix in `PolicyExecutor`, and a regression test covering the
no-input case.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-06-19 17:06:28 +00:00
Anthony StirlingandGitHub 3870ac3d7d Add desktop mobile-upload page and fix LAN QR URL (#6736)
# Description of Changes

Desktop can not use QR code upload due to API backend not having UI for
it...
Because of this we add UI, has to be custom because can not support
OpenCV and in app camera due to its https requirement

<img width="919" height="2048" alt="image"
src="https://github.com/user-attachments/assets/d84c3903-fd23-421a-8919-24d89ba6c753"
/>
<img width="919" height="2048" alt="image"
src="https://github.com/user-attachments/assets/d6c8fdf3-837b-4dc3-92ea-37f7502f8462"
/>
<img width="919" height="2048" alt="image"
src="https://github.com/user-attachments/assets/fd4ce3b4-69ad-45dd-b066-bff2d082c583"
/>

<img width="1550" height="790" alt="image"
src="https://github.com/user-attachments/assets/701d4dcc-ebd6-4e03-aa7b-2d8623525fc9"
/>

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-19 15:33:49 +00:00
James BruntonandGitHub b9ea9064c7 Cache Rust build to improve Tauri build job times (#6732)
# Description of Changes
The Tauri jobs are very slow, especially the Linux ones, which can take
>1hr to build all the necessary code. A lot of that is because of the
actual Rust compilation, which isn't cached at all as far as I can tell.
This introduces a cache step for the Rust dependencies, so PRs will just
reuse the compiled Rust from the last build of main (if it's safe to do
so).
2026-06-19 15:27:14 +00:00
Anthony StirlingandGitHub fe7a2a5ac7 Fix Multi Tool page rotation lost on save (#6733)
# Description of Changes

Rotating a page in the Multi Tool and saving could leave the page at its
original rotation (the change appeared lost), with inconsistent results
across pages.

- Page rotation is now always written on export, including 0°, so
rotating a page that already had a non-zero rotation in the source PDF
(e.g. a 270° page rotated back to upright) is no longer dropped.
- Per-page rotation is always read when building the Multi Tool
document, so pages keep their true orientation regardless of file size.
- Rotation is only applied after a page imports successfully, avoiding a
misaligned or failed export when an import fails.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-19 14:19:51 +00:00
James BruntonandGitHub 6a9876a067 Fix more any typing usage in the frontend (#6664)
# Description of Changes
Continued effort to remove the remaining uses of the `any` type from our
TS code. The vast majority of these uses that it cleans up was just
catching errors as `any`, which are pretty simple to fix. I couldn't
completely remove the `any` type usage from `core/tools` because there
were cascading issues from a couple of the files in there (most notably
Automate) but still, moving in the right direction.
2026-06-19 13:37:53 +00:00
James BruntonandGitHub 3793a6df52 Fix bad frontend architecture (#6730)
# Description of Changes
#6727 introduced frontend code which goes against the architecture, so
this PR re-implements it in the architecture properly, along with
another bad Tauri check that I found in the source. I also updated the
`AGENTS.md` file to use Claude's "read this file" syntax to try and
force AI to actually read the file instead of just suggesting that it
does it.
2026-06-19 12:34:13 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Anthony Stirling
66841db2b7 build(deps): bump js-yaml from 4.1.1 to 4.2.0 in /devTools (#6680)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md">js-yaml's
changelog</a>.</em></p>
<blockquote>
<h2>[4.2.0] - 2026-06-01</h2>
<h3>Added</h3>
<ul>
<li>Added <code>docs/safety.md</code> with notes about processing
untrusted YAML.</li>
<li>Added <code>maxDepth</code> (100) loader option. Not a problem, but
gives a better
exception instead of RangeError on stack overflow.</li>
<li>Added <code>maxMergeSeqLength</code> (20) loader option. Not a
problem after <code>merge</code> fix,
but an additional restriction for safety.</li>
<li>Added sourcemaps to <code>dist/</code> builds.</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Stop resolving numbers with underscores as numeric scalars, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/627">#627</a>.</li>
<li>Switched dev toolchains to Vite / neostandard.</li>
<li>Updated demo.</li>
<li>Reorganized tests.</li>
<li><code>dist/</code> files are no longer kept in the repository.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Fix parsing of properties on the first implicit block mapping key,
<a
href="https://redirect.github.com/nodeca/js-yaml/issues/62">#62</a>.</li>
<li>Fix trailing whitespace handling when folding flow scalar lines, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Reject top-level block scalars without content indentation, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/280">#280</a>.</li>
<li>Ensure numbers survive round-trip, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/737">#737</a>.</li>
<li>Fix test coverage for issue <a
href="https://redirect.github.com/nodeca/js-yaml/issues/221">#221</a>.</li>
<li>Fix flow scalar trailing whitespace folding, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Fix digits in YAML named tag handles.</li>
</ul>
<h3>Security</h3>
<ul>
<li>Fix potential DoS via quadratic complexity in merge - deduplicate
repeated
elements (makes sense for malformed files &gt; 10K).</li>
</ul>
<h2>[3.14.2] - 2025-11-15</h2>
<h3>Security</h3>
<ul>
<li>Backported v4.1.1 fix to v3</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/nodeca/js-yaml/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=js-yaml&package-manager=npm_and_yarn&previous-version=4.1.1&new-version=4.2.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-19 12:15:23 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Anthony Stirling
377677c182 build(deps-dev): bump js-yaml from 4.1.1 to 4.2.0 in /frontend (#6677)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md">js-yaml's
changelog</a>.</em></p>
<blockquote>
<h2>[4.2.0] - 2026-06-01</h2>
<h3>Added</h3>
<ul>
<li>Added <code>docs/safety.md</code> with notes about processing
untrusted YAML.</li>
<li>Added <code>maxDepth</code> (100) loader option. Not a problem, but
gives a better
exception instead of RangeError on stack overflow.</li>
<li>Added <code>maxMergeSeqLength</code> (20) loader option. Not a
problem after <code>merge</code> fix,
but an additional restriction for safety.</li>
<li>Added sourcemaps to <code>dist/</code> builds.</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Stop resolving numbers with underscores as numeric scalars, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/627">#627</a>.</li>
<li>Switched dev toolchains to Vite / neostandard.</li>
<li>Updated demo.</li>
<li>Reorganized tests.</li>
<li><code>dist/</code> files are no longer kept in the repository.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Fix parsing of properties on the first implicit block mapping key,
<a
href="https://redirect.github.com/nodeca/js-yaml/issues/62">#62</a>.</li>
<li>Fix trailing whitespace handling when folding flow scalar lines, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Reject top-level block scalars without content indentation, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/280">#280</a>.</li>
<li>Ensure numbers survive round-trip, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/737">#737</a>.</li>
<li>Fix test coverage for issue <a
href="https://redirect.github.com/nodeca/js-yaml/issues/221">#221</a>.</li>
<li>Fix flow scalar trailing whitespace folding, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Fix digits in YAML named tag handles.</li>
</ul>
<h3>Security</h3>
<ul>
<li>Fix potential DoS via quadratic complexity in merge - deduplicate
repeated
elements (makes sense for malformed files &gt; 10K).</li>
</ul>
<h2>[3.14.2] - 2025-11-15</h2>
<h3>Security</h3>
<ul>
<li>Backported v4.1.1 fix to v3</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/nodeca/js-yaml/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=js-yaml&package-manager=npm_and_yarn&previous-version=4.1.1&new-version=4.2.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-19 12:15:13 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Anthony Stirling
f25f7e5fc9 build(deps): bump dompurify from 3.4.1 to 3.4.11 in /frontend (#6722)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.1 to
3.4.11.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/cure53/DOMPurify/releases">dompurify's
releases</a>.</em></p>
<blockquote>
<h2>DOMPurify 3.4.11</h2>
<ul>
<li>Fixed an issue with a leaky config for hooks via
<code>setConfig</code>, thanks <a
href="https://github.com/trace37labs"><code>@​trace37labs</code></a></li>
<li>Bumped vulnerable development dependencies to arrive at plain 0 with
<code>npm audit</code></li>
<li>Updated the <code>osv-scanner</code> suppression list as no
vulnerable dependencies are left for now</li>
<li>Updated up the linting tool-chain and removed now-redundant lint
directives</li>
<li>Updated the documentation is several spots, README, wiki, etc.</li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.10</h2>
<ul>
<li>Refactored codebase for clarity: extracted the public type
declarations into <code>types.ts</code></li>
<li>Decomposed the three largest sanitizer functions into focused
helpers</li>
<li>Removed duplicated defaults and dead branches, consolidated
<code>SAFE_FOR_TEMPLATES</code> scrubbing into single shared path</li>
<li>Improved per-node performance by hoisting the mXSS probe regexes and
testing <code>textContent</code> before <code>innerHTML</code></li>
<li>Added a deterministic micro-benchmark harness (<code>npm run
bench</code>) with a <code>--compare</code> mode</li>
<li>Reduced CI cost by running the full three-engine browser suite once
per PR</li>
<li>Refreshed the <code>demos/</code> folder so every demo runs again,
and added a SVG-via-<code>&lt;img&gt;</code> demo</li>
<li>Documented the bench and <code>test:happydom</code> scripts in the
README</li>
<li>Completed the Attack Classes &amp; Bypass History wiki page</li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.9</h2>
<ul>
<li>Further improved the handling of Trusted Types config options,
thanks <a
href="https://github.com/offset"><code>@​offset</code></a></li>
<li>Further improved the handling of <code>IN_PLACE</code> sanitization,
thanks <a
href="https://github.com/mozfreddyb"><code>@​mozfreddyb</code></a></li>
<li>Added more test coverage for <code>IN_PLACE</code> and Trusted Types
related usage</li>
<li>Bumped several dependencies where possible</li>
<li>Updated README and wiki with more accurate documentation &amp;
attack samples</li>
</ul>
<h2>DOMPurify 3.4.8</h2>
<ul>
<li>Cleaned up the repository root, renamed some and removed unneeded
files</li>
<li>Fixed an issue with handling of Trusted Types policies, thanks <a
href="https://github.com/fulstadev"><code>@​fulstadev</code></a></li>
<li>Fixed the node iterator for better template scrubbing, thanks <a
href="https://github.com/IamLeandrooooo"><code>@​IamLeandrooooo</code></a></li>
<li>Included formerly missing LICENSE-MPL in published npm package,
thanks <a
href="https://github.com/asamuzaK"><code>@​asamuzaK</code></a></li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.7</h2>
<ul>
<li>Hardened the handling of Shadow Roots when using
<code>IN_PLACE</code>, thanks <a
href="https://github.com/GameZoneHacker"><code>@​GameZoneHacker</code></a></li>
<li>Removed a problem leading to permanent hook pollution, thanks <a
href="https://github.com/offset"><code>@​offset</code></a></li>
<li>Refactored the test suite and expanded test coverage
significantly</li>
</ul>
<h2>DOMPurify 3.4.6</h2>
<ul>
<li>Fixed several issues with DOM Clobbering in <code>IN_PLACE</code>
mode, thanks <a
href="https://github.com/offset"><code>@​offset</code></a> &amp; <a
href="https://github.com/Bankde"><code>@​Bankde</code></a></li>
<li>Hardened the checks for cross-realm <code>IN_PLACE</code> and Shadow
DOM sanitization, thanks <a
href="https://github.com/offset"><code>@​offset</code></a> &amp; <a
href="https://github.com/Bankde"><code>@​Bankde</code></a></li>
<li>Added more test coverage for <code>IN_PLACE</code> and general DOM
Clobbering attacks</li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.5</h2>
<ul>
<li>Fixed a bypass caused by the new HTML element
<code>selectedcontent</code> added in 3.4.4, thanks <a
href="https://github.com/KabirAcharya"><code>@​KabirAcharya</code></a></li>
</ul>
<p><strong>Note that this is a security release for an issue introduced
in 3.4.4 and should be upgraded to immediately.</strong></p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/cure53/DOMPurify/commit/0cae5187403132f96a6d357649e4b15633fc210a"><code>0cae518</code></a>
release: 3.4.11 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1494">#1494</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/6ee5716f8336989753611beeca364957c0eb0c3e"><code>6ee5716</code></a>
release: 3.4.10 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1478">#1478</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/52102472d46035857c52df19e44285f8a1e102fc"><code>5210247</code></a>
release: 3.4.9 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1459">#1459</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/bcdd8285412dc9c4c149652aed2d712e790d6ccf"><code>bcdd828</code></a>
release: 3.4.8 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1439">#1439</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/ca30f070c360df162a3e3848e80e6fd3c9e74bff"><code>ca30f07</code></a>
release: 3.4.7 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1414">#1414</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/bb7739e5bccec7e1ab3dae3f3e42d02db3acaaae"><code>bb7739e</code></a>
release: 3.4.6 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1394">#1394</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/011b0c78f2a0f57ee54f5fcccb697a46ca6e63ea"><code>011b0c7</code></a>
release: 3.4.5 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1382">#1382</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/5817ad969c15e67dfcd6cb37248d6e9c1553e7c3"><code>5817ad9</code></a>
release: 3.4.4 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1374">#1374</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/520edb0371a9638f9b51f1798051299a250c686b"><code>520edb0</code></a>
release: 3.4.3 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1352">#1352</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/6f67fd396a7b8c64294343999fe607ca1f5299c0"><code>6f67fd3</code></a>
Sync/3.4.2 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1322">#1322</a>)</li>
<li>See full diff in <a
href="https://github.com/cure53/DOMPurify/compare/3.4.1...3.4.11">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=dompurify&package-manager=npm_and_yarn&previous-version=3.4.1&new-version=3.4.11)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-19 12:14:27 +00:00
James BruntonandGitHub b57958531d Add message when running task with no arguments (#6731)
# Description of Changes
Add message describing the most common tasks when running `task` with no
arguments. I think this should help newcomers because `task --list` is
massive at this point and nobody's going to read through it all. Let me
know if you think any other commands should be in the default message.
2026-06-19 10:44:30 +00:00
LudyandGitHub f8ceca0c3f feat(i18n): sync editor translations with pluralization support and new UI strings (#6565)
# Description of Changes

## What was changed

- Updated editor translation files across multiple locales.
- Migrated numerous count-based translation keys from legacy
`{{plural}}` handling to ICU-style plural forms using `_one`, `_other`,
and where applicable `_zero` variants.
- Added translations and localization keys for newly introduced features
and UI areas, including:
  - Stirling Agents
  - Chat interface and quick actions
  - Files management and folder organization
  - Desktop update workflow
  - Folder scanning warnings
  - Team and workspace management
  - Sharing and upload dialogs
  - Comparison status messages
  - Relative time formatting
  - Additional tool panel and update UI strings
- Added missing translation entries required by recently introduced
frontend functionality.
- Reorganized some translation sections to maintain consistency and key
ordering.

## Why the change was made

- To align locale files with the current frontend feature set.
- To support proper pluralization behavior across languages.
- To prevent missing translation keys and fallback text in newly added
UI components.
- To improve localization consistency and maintainability as the
application grows.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-19 10:34:55 +00:00
Anthony StirlingandGitHub e6d476297d Clean up update dialog UI and fix desktop external links (#6727) 2026-06-18 22:20:02 +01:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
3456316569 Update Backend 3rd Party Licenses (#6719)
Auto-generated by stirlingbot[bot]

This PR updates the backend license report based on dependency changes.

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2026-06-18 19:54:02 +00:00
215bba39bc Give SaaS users their own team and harden the user list endpoint (#6717)
# Description of Changes

Previously, new SaaS users were placed on a shared Default team and then
migrated to their own. A race (or a failed migration, or an
anonymous→registered upgrade) could leave them stuck on that shared
team, where unrelated users could see each other
Instead they now get their own personal team during creation so
unrelated users no longer collide on one team. SaaS-only
(@Profile("saas")); self-host's Default behaviour is untouched.
Also happens during call to avoid uncaught users

Scope GET /api/v1/user/users. Anonymous callers get 403; a caller on a
system team (Default/Internal) gets only themselves, not the team's
members.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-06-18 16:27:22 +00:00
900b66b030 chore(saas): remove dead ErrorTrackingService island + credits path exclusion (#6718)
## What

Residual dead-code cleanup following the credits engine teardown
(#6687).

- **Delete the `ErrorTrackingService` dead island** (6 files): the
service, `UserErrorTrackerRepository`, `UserErrorTracker`,
`ProcessingErrorType`, `CreditsProperties`, and
`ErrorTrackingServiceTest`. These formed a self-referential cluster with
**zero external callers** once the credit machinery was removed.
- **Remove `/api/v1/credits/**`** from both `excludePathPatterns` blocks
in `PaygWebMvcConfig` — the credits controller no longer exists, so the
exclusion is defunct. (spotless collapsed the lists to one line.)

## Verification

- `./gradlew :saas:compileJava :saas:compileTestJava` → **BUILD
SUCCESSFUL**
- grep confirms zero dangling references to the deleted types

## Not in scope (deliberately deferred)

Destructive DB drops
(`user_credits`/`team_credits`/`user_subscription_plans` tables, dead
`payg_shadow_charge` columns, `user_error_tracker` table) are gated
behind the post-release soak (`live_ratio==1.0 ≥7d`) and tracked in a
separate bundle.

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-06-18 14:32:54 +00:00
c3795c1a3c fix(viewer): wire Ctrl+A to select all text in the PDF (#6517)
# Description of Changes

Allow Ctrl A support in viewer and fix select text to copy issues via a
hovering copy button

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-06-18 13:50:08 +00:00
c8925acee7 add prerendered Open Graph previews and OG card generator (#6661)
# Description of Changes

add prerendered Open Graph previews and OG card generator
so that /compresss etc shows a pre generated static html file (Since
google etc doenst render javascript)


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-06-18 13:49:16 +00:00
James BruntonandGitHub eb08e60d67 Redesign pre-commit commands to run through Task (#6670)
# Description of Changes
The `pre-commit` commands in this repo are inconsistent with the rest of
the dev workflow, as they are impossible to run through Task and they
can cause CI to fail with no way for a developer to run the `pre-commit`
scripts after they've failed. This PR adds `task pre-commit` (and `task
pre-commit:fix`) and then hooks up the existing `pre-commit` hooks and
CI to call the Task rule, so if developers are using pre-commit hooks
then they should still work, but they're also runnable without using
pre-commit at all.

I think it'd be worth reviewing what we're actually running at
pre-commit in the future because I'm not entirely convinced by all of
the scripts that we are running, but this should at least make what we
have properly enforced and usable by all devs.
2026-06-18 12:55:53 +00:00
EthanHealy01andGitHub 18da914bf9 fix theme issues, remove dead rainbow mode code, standardize theme us… (#6668)
Fix issues with the theme of the app that caused some things to persist
in light mode/dark mode whilst the rest of the app was the opposite
theme.

Removed dead rainbow mode code.

Added system theme option to settings.
2026-06-18 12:42:19 +00:00
Reece BrowneandGitHub 8f46ca0d92 feat(policies): lock policies to the SaaS build + profile (#6702)
## What & why

Policies (automation-backed enforcement) execute and bill through the
cloud backend, so the feature should only be available in the hosted
**SaaS** product — not in self-hosted proprietary or core builds. Today
it's enabled in the proprietary build (and the API is exposed in any
proprietary backend), so this locks it to SaaS on both layers.

## Frontend (build-flavor gate)

`POLICIES_ENABLED` is the single gate `usePoliciesEnabled` uses (rail +
auto-run controller).

- `proprietary` flag → **`false`** (self-hosted web no longer shows
policies)
- new `src/saas/constants/featureFlags.ts` → re-exports proprietary
flags, overrides `POLICIES_ENABLED = true`
- new `src/desktop/constants/featureFlags.ts` → same `true` override —
**required**: desktop's `@app` alias has no saas layer, and desktop
already gates policies on `POLICIES_ENABLED && useConfirmedSaaSMode()`,
so without a `true` here that runtime gate could never be satisfied.
Behaviour unchanged: desktop shows policies only when connected to SaaS.
- `PoliciesSidebar.test` mocks the flag on (it tests the component, not
the build gate — same pattern the existing `usePolicyAutoRun.retry.test`
uses).

## Backend (`@Profile("saas")` gate)

The saas backend runs under the `saas` Spring profile (as
`EntitlementGuard`, the AI controllers, etc. already do). The policy
beans are now `@Profile("saas")`, so `/api/v1/policies/*` and the
auto-run triggers exist **only** in the saas backend:

`PolicyController`, `PolicyEngine`, `PolicyRunner`, `PolicyRunRegistry`,
`PolicyValidator`, `JpaPolicyStore`, `FolderInputSource`,
`FolderOutputSink`, `InlineOutputSink`, `PolicyAccessGuard`,
`FolderAccessGuard`, `FolderWatchTrigger`, `ScheduleTrigger`,
`PolicyTriggerManager`.

**Deliberately *not* gated:** `PolicyExecutor` — `AiWorkflowService`
(always-on) injects it to run ad-hoc pipelines, so it stays
profile-free. It only depends on shared infra (`InternalApiClient`,
`ToolMetadataService`, `TempFileManager`, `ObjectMapper`), so leaving it
on is safe. Gating the engine/store/triggers as a set keeps wiring
consistent (nothing un-gated depends on a gated bean).

The saas `PolicyManagementAuthority` impl
(`TeamLeaderPolicyManagementAuthority`, `@Profile("saas")`) satisfies
`PolicyAccessGuard` in the saas context.
`AdminPolicyManagementAuthority` (`@Profile("!saas")`) becomes an unused
orphan in non-saas builds — harmless; left as-is rather than expanding
this PR's scope.

## Testing
- Frontend: full suite **869 pass**; typecheck clean on
proprietary/saas/core.
- Backend: `:proprietary` compiles, spotless clean, policy tests pass,
and the proprietary (non-saas) Spring context still boots with the
policy beans gated out (verified via the MCP `@SpringBootTest`
integration tests — no missing-bean failures).

Net: SaaS web build + desktop-in-SaaS-mode get policies (UI + API);
self-hosted proprietary and core get neither the UI nor the
`/api/v1/policies` endpoints.
2026-06-18 11:05:55 +00:00
Anthony StirlingandGitHub 9a3bc6b47f Add cloud-aware delete and version history to My Files (#6704)
## /files
- Cloud-aware delete: choose device / cloud / both
- `/files` left rail always collapsed
- Details panel: pinned buttons, collapsible info, smaller preview
- Removed redundant Quick view
- New i18n keys added to en-US/en-GB

## Sidebar 
- Sidebar kebab: upload to server, delete, version history (+ cloud
badge)
- Version history modal everywhere files appear
2026-06-18 10:33:18 +00:00
Reece BrowneandGitHub 2b05865a84 Portal: unified-design surfaces (Policies, Users, Components, Agent Builder, Editor deploy) + Settings rebuild (#6696)
Builds the remaining developer-portal surfaces from the unified design
and rebuilds Settings, on top of the portal scaffold merged in #6686.
All tier-aware, mock-driven (MSW), componentised with Storybook
coverage. Touches only `frontend/portal` + `frontend/shared` — the
editor is untouched.

## New surfaces
- **Policies** — org-wide governance across the five categories
(Ingestion / Security / Compliance / Routing / Retention) with a
designer + per-doc-type overrides
- **Users** — members, roles, invite, tier-scaled SSO/SCIM access
- **Components** — embeddable `@stirling/*` SDK catalogue with
per-action pricing
- **Getting Started** — three-step funnel (use case → analyse a document
→ API key + snippets)
- **Agent Builder** — agent lifecycle (scenarios, tool modes,
evals/golden-sets, versions), reached from Sources
- **Editor deployment** — deploy/pair/operate the editor (targets,
pairing, health, credential rotation, air-gapped bundle), reached from
Infrastructure

## Reworks
- **Documents** → review/approval queue (confidence, extractions, audit
drawer, zero-standing-access elevation); the doc-type catalogue is
retained as a second tab
- **Pipelines** → golden-set pass column + "Promoted from the Editor"
section
- **Infrastructure** → new **Models** tab; deeper **Security** (managed
/ BYOK / HYOK + SOC 2 / ISO 27001 / HIPAA / GDPR / PCI attestations)
- **Home** → "What runs on your PDFs" policy summary + tier-aware
processing-status strip + pipeline-fork wizard

## Settings & shared
- New shared **`SettingsShell`** (grouped left-nav + content pane),
modelled on the editor's account-settings modal so both apps can
converge on one layout
- Portal **Settings** rebuilt on it as scoped sections — Account /
Workspace / Admin (Authentication, Active sessions, Early access)

## Brand
- Adopt the editor's brand mark + favicon; sidebar reads **Stirling
Processor**; app-switcher labels the active app "Processor"

## Mock contract
- Every surface follows the 3-layer pattern (typed `api/*` → MSW handler
→ fixtures); new endpoints documented in `MOCKS.md`. The read contract
is backend-ready; writes are marked `// TODO(backend): <METHOD> <path>`.

## Verification
- tsc (portal + shared) ✓ · eslint ✓ · dpdm (no circular) ✓ ·
`build:portal` ✓ · `storybook:build` ✓ · Prettier ✓

## Deferred (noted, not in scope)
- Unified shell / auth / role→surface routing / Workspace=Plan
(architectural epic)
- Tier rename (Editor / Processor / Bespoke) and the Usage flat-pricing
+ PAYG quick-amounts + Bespoke modal
- Editor adopting the shared `SettingsShell`; converting marked
write-stubs into live `api/` seams
2026-06-18 10:28:03 +00:00
James BruntonandGitHub 0c503cc41d Fix all top-level dev tasks treating engine as enabled (#6705)
# Description of Changes
Currently, `task dev` explicitly calls the backend with
`AIENGINE_ENABLED=true` even though it isn't being spawned, so you just
get a dead FAB in the UI. This PR fixes it so that the engine will only
be enabled for tasks that will actually spawn the engine.

It also fixes a bug with the chat which makes it unusable locally. The
API path was not going through `apiClient` so for local dev you end up
with `//api/v1/...` which is not a valid path, so you get CORS errors
when trying to connect to the AI engine.
2026-06-18 10:08:50 +00:00
albanobattistellaandGitHub b1fef4c647 Update Italian translations (#6713) 2026-06-18 08:35:31 +00:00
EthanHealy01andGitHub 06254853af allow drag and drop onto left files section and make top bar slightly smaller (#6711)
<img width="1261" height="984" alt="Screenshot 2026-06-17 at 5 59 30 PM"
src="https://github.com/user-attachments/assets/849dee17-1927-4336-81fc-dff7e91e55e7"
/>
2026-06-18 08:28:11 +00:00
Anthony StirlingandGitHub d9e6041a75 set z-index on config dropdowns so they render above the modal (#6674) 2026-06-18 09:08:50 +01:00
Anthony StirlingandGitHub 8f81fdc762 Use glibc base for ultra-lite and bundle per-arch JPDFium natives (#6706) 2026-06-18 08:32:55 +01:00
939 changed files with 128390 additions and 69921 deletions
+4 -4
View File
@@ -20,8 +20,8 @@ set -e
# - To build the project, use:
# ./gradlew build
#
# - For running pre-commit hooks (if configured), use:
# pre-commit run --all-files
# - To run the lint/format/secret checks, use:
# task pre-commit
#
# Make sure you are in the project root directory after this script executes.
# =============================================================================
@@ -70,6 +70,6 @@ echo ""
echo " To build the project: "
echo -e "\e[34m gradle build\e[0m"
echo ""
echo " To run pre-commit hooks (if configured):"
echo -e "\e[34m pre-commit run --all-files -c .pre-commit-config.yaml\e[0m"
echo " To run the lint/format/secret checks:"
echo -e "\e[34m task pre-commit\e[0m"
echo "=================================================================="
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.12.0
pkgver=2.13.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.12.0
pkgver=2.13.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
@@ -1 +0,0 @@
pre-commit
-121
View File
@@ -1,121 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in'
#
cfgv==3.5.0 \
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
# via pre-commit
distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
# via virtualenv
filelock==3.29.0 \
--hash=sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90 \
--hash=sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258
# via
# python-discovery
# virtualenv
identify==2.6.19 \
--hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \
--hash=sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842
# via pre-commit
nodeenv==1.10.0 \
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
# via pre-commit
platformdirs==4.9.6 \
--hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \
--hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917
# via
# python-discovery
# virtualenv
pre-commit==4.6.0 \
--hash=sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9 \
--hash=sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b
# via -r .github/scripts/requirements_pre_commit.in
python-discovery==1.2.2 \
--hash=sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb \
--hash=sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a
# via virtualenv
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via pre-commit
virtualenv==21.2.4 \
--hash=sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac \
--hash=sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada
# via pre-commit
+12
View File
@@ -51,3 +51,15 @@ jobs:
name: playwright-nightly-${{ github.run_id }}
path: frontend/editor/playwright-report/
retention-days: 14
# Builds all desktop platforms on a schedule so the Rust dependency cache is
# written on main, where PR and merge-queue tauri builds can restore it.
warm-tauri-cache:
name: Warm Tauri Rust cache
permissions:
contents: read
pull-requests: write
uses: ./.github/workflows/tauri-build.yml
with:
platform: all
secrets: inherit
+9 -24
View File
@@ -1,8 +1,7 @@
name: Pre-commit
# Runs `pre-commit run` for ruff / codespell / gitleaks / EOF / trailing-ws.
# Called from build.yml on PRs and merge_group; also runnable on demand via
# workflow_dispatch for manual local-equivalent linting.
# Runs the repo-wide lint/format/secret checks via `task pre-commit`.
# Called from build.yml on PRs and merge_group; also runnable on demand via workflow_dispatch.
on:
workflow_call:
workflow_dispatch:
@@ -13,10 +12,6 @@ permissions:
jobs:
pre-commit:
runs-on: ubuntu-latest
env:
# Prevents sdist builds → no tar extraction
PIP_ONLY_BINARY: ":all:"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -29,23 +24,13 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
python-version: 3.12
cache: "pip" # caching pip dependencies
cache-dependency-path: ./.github/scripts/requirements_pre_commit.txt
enable-cache: true
- name: Run Pre-Commit Hooks
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_pre_commit.txt
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
- name: Run Pre-Commit
run: |
pre-commit run ruff --all-files -c .pre-commit-config.yaml
pre-commit run ruff-format --all-files -c .pre-commit-config.yaml
pre-commit run codespell --all-files -c .pre-commit-config.yaml
pre-commit run gitleaks --all-files -c .pre-commit-config.yaml
pre-commit run end-of-file-fixer --all-files -c .pre-commit-config.yaml
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
git diff --exit-code
- name: Run pre-commit checks
run: task pre-commit
+1 -1
View File
@@ -155,9 +155,9 @@ jobs:
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# No BASE_VERSION pin: inherit the Dockerfile ARG default (single source of truth).
build-args: |
VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
BASE_VERSION=1.0.0
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
+11 -3
View File
@@ -58,15 +58,23 @@ jobs:
- name: Install Python dependencies
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt -r ./.github/scripts/requirements_pre_commit.txt
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
- name: Sync translation TOML files
run: |
python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
- name: pre-commit run
- name: Sort translation TOML files
run: |
pre-commit run toml-sort-fix --all-files
task pre-commit:toml-sort FIX=1
- name: Commit translation files
run: |
+14
View File
@@ -115,6 +115,20 @@ jobs:
toolchain: stable
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
# Cache the Cargo registry and compiled dependency crates so the build
# only recompiles the app crate. Written on main; PRs and the merge queue
# restore from it.
- name: Cache Rust build
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: frontend/editor/src-tauri
# Stable key shared across workflows so the nightly warmer.
# rust-cache still appends OS + rustc + Cargo.lock.
shared-key: tauri-${{ matrix.name }}
save-if: ${{ github.ref == 'refs/heads/main' }}
# Save the dependency cache even if a later step fails
cache-on-failure: true
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+8
View File
@@ -46,6 +46,12 @@ app/core/storage/
# These are generated by npm build and should not be committed
app/core/src/main/resources/static/assets/
app/core/src/main/resources/static/index.html
# Prerendered per-route SPA pages (OG/social-preview), e.g. compress.html. api-landing.html is source.
app/core/src/main/resources/static/*.html
!app/core/src/main/resources/static/api-landing.html
!app/core/src/main/resources/static/mobile-upload.html
# Prerendered nested-route pages (e.g. settings/people.html)
app/core/src/main/resources/static/settings/
app/core/src/main/resources/static/locales/
app/core/src/main/resources/static/Login/
app/core/src/main/resources/static/classic-logo/
@@ -53,6 +59,8 @@ app/core/src/main/resources/static/modern-logo/
app/core/src/main/resources/static/og_images/
app/core/src/main/resources/static/samples/
app/core/src/main/resources/static/manifest-classic.json
app/core/src/main/resources/static/og-metadata.json
app/core/src/main/resources/static/sw-folder-retry.js
app/core/src/main/resources/static/robots.txt
app/core/src/main/resources/static/pdfium/
app/core/src/main/resources/static/pdfjs/
+8 -1
View File
@@ -1,4 +1,4 @@
# PostHog project-level key — phc_ prefix keys are public/client-side by design
# PostHog project-level key - phc_ prefix keys are public/client-side by design
# (PostHog client-side tracking embeds them in the browser bundle). Committed
# intentionally in #6150 so engine/.env has a working default, with real
# credentials overridden via engine/.env.local.
@@ -12,3 +12,10 @@ app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiK
testing/compose/docker-compose-keycloak-mcp.yml:generic-api-key:25
testing/compose/validate-mcp-apikey.sh:curl-auth-header:73
testing/compose/validate-mcp-test.sh:curl-auth-header:92
testing/compose/validate-mcp-test.sh:curl-auth-header:116
# Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...).
frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
+12 -50
View File
@@ -1,52 +1,14 @@
# The actual checks live in .taskfiles/pre-commit.yml (with helper scripts under
# scripts/pre-commit/) and are driven by Task. This hook just delegates to `task
# pre-commit` so the git pre-commit hook, CI and a manual `task pre-commit` all
# run the exact same thing. Requires `task` and `uv` on PATH. To auto-fix instead
# of only checking, run `task pre-commit:fix`.
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.14
- repo: local
hooks:
- id: ruff
args:
- --fix
- --line-length=127
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
exclude: (split_photos.py)
- id: ruff-format
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
exclude: (split_photos.py)
- repo: https://github.com/codespell-project/codespell
rev: v2.4.2
hooks:
- id: codespell
args:
- --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment
- --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2
files: \.(html|css|js|py|md)$
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|frontend/editor/public/vendor|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.0
hooks:
- id: gitleaks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: end-of-file-fixer
files: ^.*(\.js|\.java|\.py|\.yml)$
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
- id: trailing-whitespace
files: ^.*(\.js|\.java|\.py|\.yml)$
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
- repo: https://github.com/pappasam/toml-sort
rev: v0.24.4
hooks:
- id: toml-sort-fix
files: frontend/editor/public/locales/.*\.toml$
args: ['--in-place', '--all', '--ignore-case']
# - repo: https://github.com/thibaudcolas/pre-commit-stylelint
# rev: v16.21.1
# hooks:
# - id: stylelint
# additional_dependencies:
# - stylelint@16.21.1
# - stylelint-config-standard@38.0.0
# - "@stylistic/stylelint-plugin@3.1.3"
# files: \.(css)$
# args: [--fix]
- id: task-pre-commit
name: task pre-commit
entry: task pre-commit
language: system
pass_filenames: false
always_run: true
+8 -3
View File
@@ -23,7 +23,9 @@ tasks:
vars:
PORT: '{{.PORT}}'
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
dev:proprietary:
desc: "Start backend dev server in proprietary mode"
@@ -31,13 +33,15 @@ tasks:
vars:
PORT: '{{.PORT | default "8080"}}'
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
env:
SERVER_PORT: '{{.PORT}}'
cmds:
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
platforms: [windows]
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
@@ -60,12 +64,13 @@ tasks:
# Override to "" to run the pure `saas` profile against your own SAAS_DB_*.
PROFILES: '{{.PROFILES | default "dev"}}'
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
env:
SERVER_PORT: '{{.PORT}}'
STIRLING_FLAVOR: saas
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{if .AIENGINE_URL}}true{{else}}false{{end}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
cmds:
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}"
+73 -3
View File
@@ -40,6 +40,21 @@ tasks:
cmds:
- node editor/scripts/generate-icons.js
prepare:og:
internal: true
run: when_changed
desc: "Regenerate OG/social-preview metadata from the tool registry"
cmds:
- node editor/scripts/generate-og-metadata.mjs
sources:
- editor/src/core/types/toolId.ts
- editor/src/core/utils/urlMapping.ts
- editor/src/core/data/useTranslatedToolRegistry.tsx
- editor/public/og_images/*.png
generates:
- editor/src/core/data/ogImageMap.json
- editor/public/og-metadata.json
prepare:
desc: "Set up dev environment"
run: when_changed
@@ -49,6 +64,7 @@ tasks:
- task: prepare:env
vars: { MODE: '{{.MODE}}' }
- prepare:icons
- prepare:og
# ============================================================
# Development
@@ -114,9 +130,34 @@ tasks:
dev:portal:
desc: "Start developer portal dev server"
ignore_error: true
deps: [install]
vars:
PORT: '{{.PORT | default "5173"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
EDITOR_URL: '{{.EDITOR_URL | default ""}}'
OPEN: '{{.OPEN | default ""}}'
SUBPATH: '{{.SUBPATH | default ""}}'
MOCKS: '{{.MOCKS | default ""}}'
env:
BACKEND_URL: '{{.BACKEND_URL}}'
cmds:
- npx vite portal --port {{.PORT | default "5173"}}{{if .OPEN}} --open{{end}}
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}{{if .MOCKS}}VITE_PORTAL_MOCKS={{.MOCKS}} {{end}}{{if .EDITOR_URL}}VITE_EDITOR_URL={{.EDITOR_URL}} {{end}}npx vite portal --port {{.PORT}}{{if .OPEN}} --open{{end}}'
dev:portal:proxy:serve:
internal: true
vars:
PORT: '{{.PORT | default "3000"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL | default ""}}'
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL | default ""}}'
env:
PORT: '{{.PORT}}'
BACKEND_URL: '{{.BACKEND_URL}}'
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL}}'
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL}}'
cmds:
- npx tsx scripts/dev-origin-proxy.ts
# ============================================================
# Build
@@ -137,8 +178,10 @@ tasks:
build:proprietary:
desc: "Build for proprietary mode"
deps: [prepare]
vars:
PREVIEW: '{{.PREVIEW | default ""}}'
cmds:
- npx vite build editor --mode proprietary
- '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build editor --mode proprietary'
build:saas:
desc: "Build for SaaS mode"
@@ -165,8 +208,26 @@ tasks:
build:portal:
desc: "Build developer portal"
deps: [install]
vars:
SUBPATH: '{{.SUBPATH | default ""}}'
cmds:
- npx vite build portal
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}npx vite build portal'
preview:portal:proxy:
desc: "Build + serve editor + portal behind one origin (prod-like auth testing)"
deps: [prepare]
vars:
PORT: '{{.PORT | default "3000"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
env:
PORT: '{{.PORT}}'
BACKEND_URL: '{{.BACKEND_URL}}'
cmds:
- task: build:proprietary
vars: { PREVIEW: '1' }
- task: build:portal
vars: { SUBPATH: portal }
- npx tsx scripts/dev-origin-proxy.ts
storybook:
desc: "Start Storybook dev server"
@@ -272,6 +333,7 @@ tasks:
desc: "Typecheck scripts"
deps: [prepare]
cmds:
- npx tsc --noEmit --project scripts/tsconfig.json
- npx tsc --noEmit --project editor/scripts/tsconfig.json
typecheck:prototypes:
@@ -317,9 +379,17 @@ tasks:
- task: format:check
- task: test
og:check:
desc: "Fail if committed OG/social-preview metadata is out of date"
cmds:
- node editor/scripts/generate-og-metadata.mjs --check
check:all:
desc: "Full CI quality gate"
cmds:
# Runs first, before prepare regenerates: guards the committed og-metadata.json /
# ogImageMap.json that the Cloudflare Pages (plain `vite build`) deploy relies on.
- task: og:check
- task: typecheck:all
- task: lint
- task: format:check
+133
View File
@@ -0,0 +1,133 @@
version: '3'
# Repo-wide lint/format/secret checks - the single source of truth that the git
# pre-commit hook (.pre-commit-config.yaml) and CI (pre_commit.yml) both call.
vars:
# File selections as git pathspecs: git does the include/exclude matching, so
# there is no grep/xargs and it behaves identically on every platform.
PY_FILES: >-
'scripts/*.py'
'.github/scripts/*.py'
'app/core/src/main/resources/static/python/*.py'
':(exclude)*split_photos.py'
SPELL_FILES: >-
'*.html'
'*.css'
'*.js'
'*.py'
'*.md'
':(exclude).vscode/*'
':(exclude).devcontainer/*'
':(exclude)app/core/src/main/resources/*'
':(exclude)app/proprietary/src/main/resources/*'
':(exclude)frontend/editor/public/vendor/*'
':(exclude)*Dockerfile*'
':(exclude)*pdfjs*'
':(exclude)*thirdParty*'
':(exclude)*bootstrap*'
':(exclude)*.min.*'
':(exclude)*diff.js'
WS_FILES: >-
'*.js'
'*.java'
'*.py'
'*.yml'
':(exclude)*pdfjs*'
':(exclude)*thirdParty*'
':(exclude)*bootstrap*'
':(exclude)*.min.*'
':(exclude)*diff.js'
':(exclude).github/workflows/*'
LOCALE_TOML: 'frontend/editor/public/locales/*/translation.toml'
# gitleaks is pinned + checksum-verified by scripts/pre-commit/install_gitleaks.py,
# which owns the version and caches the binary here.
GITLEAKS_BIN: '.task/bin/gitleaks{{if eq OS "windows"}}.exe{{end}}'
tasks:
default:
desc: "Check formatting, spelling, and secrets across the repo"
cmds:
- task: ruff
- task: ruff-format
- task: codespell
- task: gitleaks
- task: whitespace
- task: toml-sort
fix:
desc: "Auto-fix formatting, spelling, and secrets issues across the repo"
cmds:
# Auto-fixers first, then the report-only tools (codespell, gitleaks) so a
# finding there does not stop the fixers from running.
- task: ruff
vars: { FIX: '1' }
- task: ruff-format
vars: { FIX: '1' }
- task: whitespace
vars: { FIX: '1' }
- task: toml-sort
vars: { FIX: '1' }
- task: codespell
- task: gitleaks
install:
desc: "Install the pinned pre-commit Python tools (ruff, codespell, toml-sort)"
run: once
cmds:
- uv sync --project scripts/pre-commit --locked
sources:
- scripts/pre-commit/uv.lock
- scripts/pre-commit/pyproject.toml
status:
- test -d scripts/pre-commit/.venv
clean:
desc: "Remove the cached gitleaks binary and the tool virtualenv"
cmds:
- cmd: rm -rf scripts/pre-commit/.venv .task/bin/gitleaks
platforms: [linux, darwin]
- cmd: cmd /c "rmdir /s /q scripts\pre-commit\.venv & del /q .task\bin\gitleaks.exe"
platforms: [windows]
ignore_error: true
# Individual checks (hidden from `task --list`, but callable, e.g.
# `task pre-commit:toml-sort FIX=1`). Pass FIX=1 to auto-fix where supported.
ruff:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync ruff check --line-length=127 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}})
ruff-format:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync ruff format {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}})
codespell:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
toml-sort:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync toml-sort --all --ignore-case {{if .FIX}}--in-place{{else}}--check{{end}} {{.LOCALE_TOML}}
whitespace:
cmds:
- uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
gitleaks:
deps: [gitleaks-bin]
# Scan staged changes only, matching the old hook: the git-mode fingerprints
# in .gitleaksignore (file:rule:line) still apply, and with nothing staged
# this is a no-op. Secrets are never auto-fixed, so FIX has no effect.
cmds:
- "{{.GITLEAKS_BIN}} git --pre-commit --redact --staged --verbose"
gitleaks-bin:
internal: true
desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin"
cmds:
- uv run --no-project python scripts/pre-commit/install_gitleaks.py
+1 -1
View File
@@ -152,7 +152,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
#### Import Paths - CRITICAL
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
For a broader explanation of the frontend layering and override architecture, see [frontend/editor/DeveloperGuide.md](frontend/editor/DeveloperGuide.md).
For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md
```typescript
// ✅ CORRECT - Use @app/* for all imports
+1 -1
View File
@@ -60,7 +60,7 @@ For full installation options (including desktop and Kubernetes), see our [Docum
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
This project uses [Task](https://taskfile.dev/) as a unified command runner for all build, dev, and test commands. Run `task install` to get started, or see the [Developer Guide](DeveloperGuide.md) for full details.
This project uses [Task](https://taskfile.dev/) as a unified command runner for all build, dev, and test commands. Run `task dev` to get started running the editor, run `task` to see the most common commands, or see the [Developer Guide](DeveloperGuide.md) for full details.
For adding translations, see the [Translation Guide](devGuide/HowToAddNewLanguage.md).
+113
View File
@@ -25,8 +25,28 @@ includes:
e2e:
taskfile: .taskfiles/e2e.yml
dir: .
pre-commit:
taskfile: .taskfiles/pre-commit.yml
dir: .
tasks:
# ============================================================
# Help (shown when you run `task` with no arguments)
# ============================================================
default:
desc: "List the most common commands"
silent: true
cmds:
- |
echo "Common commands (run 'task --list' to see all):"
echo ""
echo " task dev Start backend & frontend on free ports"
echo " task backend:dev Start backend on default port"
echo " task frontend:dev Start frontend on default port"
echo " task desktop:dev Start desktop app"
echo " task check Quality gate (lint, typecheck, test, etc.)"
# ============================================================
# Setup & Prerequisites
# ============================================================
@@ -58,6 +78,80 @@ tasks:
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
dev:portal:
desc: "Start backend + developer portal concurrently on free ports"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
dev:portal:all:
desc: "Start backend + developer portal + editor concurrently on free ports"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
# Point the portal's "Editor" app switcher at the editor we spawn here.
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
OPEN: "true"
- task: frontend:dev
vars:
PORT: '{{.EDITOR_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
dev:portal:proxy:
desc: "Editor + portal on ONE origin + backend via live dev servers (shared-token login)"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000 5173 5174{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 3}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
- task: frontend:dev:proprietary
vars:
PORT: '{{.EDITOR_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
SUBPATH: portal
MOCKS: 'false'
- task: frontend:dev:portal:proxy:serve
vars:
PORT: '{{.PROXY_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
EDITOR_DEV_URL: 'http://localhost:{{.EDITOR_PORT}}'
PORTAL_DEV_URL: 'http://localhost:{{.PORTAL_PORT}}'
dev:saas:
desc: "Start SaaS backend + frontend concurrently on free ports"
cmds:
@@ -87,6 +181,7 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
AIENGINE_ENABLED: "true"
- task: 'frontend:dev:{{.FRONTEND}}'
vars:
PORT: '{{.FRONTEND_PORT}}'
@@ -103,6 +198,23 @@ tasks:
- task: backend:build
- task: frontend:build
preview:portal:proxy:
desc: "Build + serve editor + portal on ONE origin + backend (prod-like auth test)"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
- task: frontend:preview:portal:proxy
vars:
PORT: '{{.PROXY_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
# ============================================================
# Test
# ============================================================
@@ -173,3 +285,4 @@ tasks:
- task: backend:clean
- task: frontend:clean
- task: engine:clean
- task: pre-commit:clean
@@ -1185,23 +1185,181 @@ public class GeneralUtils {
}
public String getLocalNetworkIp() {
String routed = detectLocalIpViaDefaultRoute();
if (routed != null) {
return routed;
}
try {
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
if (interfaces == null) return null;
while (interfaces.hasMoreElements()) {
NetworkInterface iface = interfaces.nextElement();
if (!iface.isUp() || iface.isLoopback() || iface.isVirtual()) continue;
Enumeration<InetAddress> addresses = iface.getInetAddresses();
while (addresses.hasMoreElements()) {
InetAddress addr = addresses.nextElement();
if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) {
return addr.getHostAddress();
}
}
}
return selectBestSiteLocalIp(collectInterfaceInfo());
} catch (Exception e) {
log.warn("Failed to detect local network IP", e);
return null;
}
}
private String detectLocalIpViaDefaultRoute() {
try (DatagramSocket socket = new DatagramSocket()) {
socket.connect(InetAddress.getByName("8.8.8.8"), 53);
InetAddress local = socket.getLocalAddress();
if (local instanceof Inet4Address
&& !local.isAnyLocalAddress()
&& !local.isLoopbackAddress()
&& !local.isLinkLocalAddress()) {
return local.getHostAddress();
}
} catch (Exception e) {
log.debug("Default-route IP detection failed; will scan interfaces", e);
}
return null;
}
private List<NetworkInterfaceInfo> collectInterfaceInfo() throws SocketException {
List<NetworkInterfaceInfo> infos = new ArrayList<>();
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
if (interfaces == null) {
return infos;
}
while (interfaces.hasMoreElements()) {
NetworkInterface iface = interfaces.nextElement();
List<String> siteLocalIpv4s = new ArrayList<>();
Enumeration<InetAddress> addresses = iface.getInetAddresses();
while (addresses.hasMoreElements()) {
InetAddress addr = addresses.nextElement();
if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) {
siteLocalIpv4s.add(addr.getHostAddress());
}
}
if (siteLocalIpv4s.isEmpty()) {
continue;
}
try {
byte[] mac = iface.getHardwareAddress();
infos.add(
new NetworkInterfaceInfo(
iface.getName(),
iface.getDisplayName(),
iface.getIndex(),
iface.isUp(),
iface.isLoopback(),
iface.isPointToPoint(),
iface.isVirtual(),
mac != null && mac.length > 0,
siteLocalIpv4s));
} catch (SocketException e) {
log.debug("Skipping interface {} while scanning for local IP", iface.getName(), e);
}
}
return infos;
}
static String selectBestSiteLocalIp(List<NetworkInterfaceInfo> interfaces) {
return interfaces.stream()
.filter(i -> i.up() && !i.loopback() && !i.pointToPoint() && !i.virtual())
.filter(i -> !isLikelyVirtualInterface(i.name(), i.displayName()))
.flatMap(
i ->
i.siteLocalIpv4s().stream()
.map(
ip ->
new ScoredAddress(
ip,
scoreInterface(i, ip),
i.index())))
.max(
Comparator.comparingInt(ScoredAddress::score)
.thenComparing(
Comparator.comparingInt(ScoredAddress::interfaceIndex)
.reversed()))
.map(ScoredAddress::ip)
.orElse(null);
}
private static int scoreInterface(NetworkInterfaceInfo iface, String ip) {
int score = 0;
if (isLikelyPhysicalInterface(iface.name(), iface.displayName())) {
score += 100;
}
if (iface.hasHardwareAddress()) {
score += 20;
}
if (ip.startsWith("192.168.")) {
score += 30;
} else if (ip.startsWith("10.")) {
score += 20;
} else {
score += 5;
}
return score;
}
static boolean isLikelyVirtualInterface(String name, String displayName) {
String n = name == null ? "" : name.toLowerCase(Locale.ROOT);
String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT);
String[] namePrefixes = {
"tun", "tap", "utun", "veth", "virbr", "vmnet", "docker", "br-", "wg", "ppp", "awdl",
"llw"
};
for (String prefix : namePrefixes) {
if (n.startsWith(prefix)) {
return true;
}
}
String[] displayMarkers = {
"vmware",
"virtualbox",
"virtual box",
"vbox",
"hyper-v",
"hyperv",
"vethernet",
"windows subsystem for linux",
"wsl",
"docker",
"tap-windows",
"tunnel",
"vpn",
"zerotier",
"tailscale",
"bluetooth",
"teredo",
"isatap",
"loopback",
"pseudo",
"virtual"
};
for (String marker : displayMarkers) {
if (d.contains(marker)) {
return true;
}
}
return false;
}
private static boolean isLikelyPhysicalInterface(String name, String displayName) {
String n = name == null ? "" : name.toLowerCase(Locale.ROOT);
String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT);
return n.startsWith("eth")
|| n.startsWith("en")
|| n.startsWith("wl")
|| n.startsWith("em")
|| d.contains("ethernet")
|| d.contains("wi-fi")
|| d.contains("wifi")
|| d.contains("wireless");
}
record NetworkInterfaceInfo(
String name,
String displayName,
int index,
boolean up,
boolean loopback,
boolean pointToPoint,
boolean virtual,
boolean hasHardwareAddress,
List<String> siteLocalIpv4s) {}
private record ScoredAddress(String ip, int score, int interfaceIndex) {}
}
@@ -0,0 +1,191 @@
package stirling.software.SPDF.pdf.parser;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import stirling.software.SPDF.pdf.parser.PageImageLocator.ImageBox;
/**
* Unit tests for {@link PageImageLocator}. PDFs are built in memory with PDFBox so each test is
* deterministic and needs no fixtures or native libraries. The locator transforms the image unit
* square through the CTM, so an image drawn at {@code (x, y)} with size {@code (w, h)} must yield
* the box {@code (x, y, x+w, y+h)}.
*/
class PageImageLocatorTest {
/** A tiny opaque raster; pixel content is irrelevant, only its placement matters. */
private static PDImageXObject tinyImage(PDDocument doc) throws Exception {
BufferedImage img = new BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB);
return LosslessFactory.createFromImage(doc, img);
}
/** Builds a one-page PDF that draws one image at the given placement. */
private static byte[] pdfWithImageAt(float x, float y, float w, float h) throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
PDImageXObject image = tinyImage(doc);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.drawImage(image, x, y, w, h);
}
return save(doc);
}
}
private static byte[] save(PDDocument doc) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
@Nested
@DisplayName("drawImage bounding boxes")
class DrawImageBoxes {
@Test
@DisplayName("a single image yields one box with the page index and CTM-derived bounds")
void singleImageBox() throws Exception {
byte[] pdf = pdfWithImageAt(100f, 200f, 50f, 80f);
try (PDDocument doc = Loader.loadPDF(pdf)) {
PageImageLocator locator = new PageImageLocator(doc.getPage(0), 0);
locator.processPage(doc.getPage(0));
List<ImageBox> boxes = locator.getImageBoxes();
assertThat(boxes).hasSize(1);
ImageBox box = boxes.get(0);
assertThat(box.pageIndex()).isZero();
assertThat(box.x1()).isCloseTo(100f, within(0.5f));
assertThat(box.y1()).isCloseTo(200f, within(0.5f));
assertThat(box.x2()).isCloseTo(150f, within(0.5f));
assertThat(box.y2()).isCloseTo(280f, within(0.5f));
}
}
@Test
@DisplayName("the supplied page index is stored on every box")
void pageIndexStored() throws Exception {
byte[] pdf = pdfWithImageAt(10f, 10f, 20f, 20f);
try (PDDocument doc = Loader.loadPDF(pdf)) {
PageImageLocator locator = new PageImageLocator(doc.getPage(0), 7);
locator.processPage(doc.getPage(0));
assertThat(locator.getImageBoxes().get(0).pageIndex()).isEqualTo(7);
}
}
@Test
@DisplayName("two images on one page yield two boxes")
void twoImages() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
PDImageXObject image = tinyImage(doc);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.drawImage(image, 50f, 50f, 30f, 30f);
cs.drawImage(image, 200f, 400f, 60f, 40f);
}
byte[] pdf = save(doc);
try (PDDocument reopened = Loader.loadPDF(pdf)) {
PageImageLocator locator = new PageImageLocator(reopened.getPage(0), 0);
locator.processPage(reopened.getPage(0));
assertThat(locator.getImageBoxes()).hasSize(2);
}
}
}
@Test
@DisplayName("a page with no images yields no boxes")
void noImages() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
byte[] pdf = save(doc);
try (PDDocument reopened = Loader.loadPDF(pdf)) {
PageImageLocator locator = new PageImageLocator(reopened.getPage(0), 0);
locator.processPage(reopened.getPage(0));
assertThat(locator.getImageBoxes()).isEmpty();
}
}
}
@Test
@DisplayName("getImageBoxes is empty before any page is processed")
void emptyBeforeProcessing() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
PageImageLocator locator = new PageImageLocator(doc.getPage(0), 0);
assertThat(locator.getImageBoxes()).isEmpty();
}
}
}
@Nested
@DisplayName("path operation no-ops")
class PathNoOps {
private PageImageLocator newLocator() {
PDPage page = new PDPage(PDRectangle.A4);
return new PageImageLocator(page, 0);
}
@Test
@DisplayName("moveTo updates the current point")
void moveToUpdatesPoint() {
PageImageLocator locator = newLocator();
locator.moveTo(12f, 34f);
Point2D current = locator.getCurrentPoint();
assertThat(current.getX()).isEqualTo(12d);
assertThat(current.getY()).isEqualTo(34d);
}
@Test
@DisplayName("lineTo updates the current point")
void lineToUpdatesPoint() {
PageImageLocator locator = newLocator();
locator.lineTo(5f, 6f);
assertThat(locator.getCurrentPoint().getX()).isEqualTo(5d);
assertThat(locator.getCurrentPoint().getY()).isEqualTo(6d);
}
@Test
@DisplayName("curveTo updates the current point to the final control point")
void curveToUpdatesPoint() {
PageImageLocator locator = newLocator();
locator.curveTo(1f, 1f, 2f, 2f, 9f, 8f);
assertThat(locator.getCurrentPoint().getX()).isEqualTo(9d);
assertThat(locator.getCurrentPoint().getY()).isEqualTo(8d);
}
@Test
@DisplayName("rectangle, clip, path and shading operations are no-ops that do not throw")
void otherOpsDoNotThrow() {
PageImageLocator locator = newLocator();
Point2D p = new Point2D.Float(0f, 0f);
// None of these record anything or alter state; they must simply not throw.
locator.appendRectangle(p, p, p, p);
locator.clip(0);
locator.closePath();
locator.endPath();
locator.strokePath();
locator.fillPath(0);
locator.fillAndStrokePath(0);
locator.shadingFill(COSName.getPDFName("Sh0"));
assertThat(locator.getImageBoxes()).isEmpty();
}
}
}
@@ -0,0 +1,270 @@
package stirling.software.common.configuration;
import static org.assertj.core.api.Assertions.assertThat;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.function.Predicate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.ApplicationProperties;
class AppConfigTest {
private ApplicationProperties applicationProperties;
private MockEnvironment env;
private AppConfig appConfig;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
env = new MockEnvironment();
appConfig = new AppConfig(env, applicationProperties);
ReflectionTestUtils.setField(appConfig, "contextPath", "/");
ReflectionTestUtils.setField(appConfig, "serverPort", "8080");
ReflectionTestUtils.setField(appConfig, "v2Enabled", true);
}
@Nested
@DisplayName("Value-backed getters and simple beans")
class SimpleBeans {
@Test
@DisplayName("getter fields reflect injected @Value values")
void valueGetters() {
assertThat(appConfig.getContextPath()).isEqualTo("/");
assertThat(appConfig.getServerPort()).isEqualTo("8080");
}
@Test
@DisplayName("v2Enabled bean mirrors the field")
void v2EnabledBean() {
assertThat(appConfig.v2Enabled()).isTrue();
}
@Test
@DisplayName("constant beans return fixed values")
void constants() {
assertThat(appConfig.appName()).isEqualTo("Stirling PDF");
assertThat(appConfig.homeText()).isEqualTo("null");
assertThat(appConfig.contextPath("/ctx")).isEqualTo("/ctx");
}
@Test
@DisplayName("appVersion resolves from version.properties on classpath")
void appVersion() {
assertThat(appConfig.appVersion()).isNotBlank();
}
@Test
@DisplayName("StirlingPDFLabel embeds version")
void stirlingLabel() {
assertThat(appConfig.stirlingPDFLabel()).startsWith("Stirling-PDF v");
}
}
@Nested
@DisplayName("Beans backed by ApplicationProperties")
class PropertyBackedBeans {
@Test
@DisplayName("loginEnabled reflects security flag")
void loginEnabled() {
applicationProperties.getSecurity().setEnableLogin(true);
assertThat(appConfig.loginEnabled()).isTrue();
}
@Test
@DisplayName("backendUrl falls back to localhost when unset")
void backendUrlFallback() {
assertThat(appConfig.getBackendUrl()).isEqualTo("http://localhost");
}
@Test
@DisplayName("backendUrl returns configured value when present")
void backendUrlConfigured() {
applicationProperties.getSystem().setBackendUrl("https://api.example.com");
assertThat(appConfig.getBackendUrl()).isEqualTo("https://api.example.com");
}
@Test
@DisplayName("languages bean returns configured languages list")
void languages() {
applicationProperties.getUi().setLanguages(List.of("en", "de"));
assertThat(appConfig.languages()).containsExactly("en", "de");
}
@Test
@DisplayName("navBarText falls back to Stirling PDF when unset")
void navBarTextFallback() {
assertThat(appConfig.navBarText()).isEqualTo("Stirling PDF");
}
@Test
@DisplayName("navBarText returns configured value")
void navBarTextConfigured() {
applicationProperties.getUi().setAppNameNavbar("My PDF");
assertThat(appConfig.navBarText()).isEqualTo("My PDF");
}
@Test
@DisplayName("enableAlphaFunctionality reflects system flag")
void alphaFunctionality() {
applicationProperties.getSystem().setEnableAlphaFunctionality(true);
assertThat(appConfig.enableAlphaFunctionality()).isTrue();
}
@Test
@DisplayName("legal text beans return configured values")
void legalBeans() {
var legal = applicationProperties.getLegal();
legal.setTermsAndConditions("terms");
legal.setPrivacyPolicy("privacy");
legal.setCookiePolicy("cookie");
legal.setImpressum("impressum");
legal.setAccessibilityStatement("a11y");
assertThat(appConfig.termsAndConditions()).isEqualTo("terms");
assertThat(appConfig.privacyPolicy()).isEqualTo("privacy");
assertThat(appConfig.cookiePolicy()).isEqualTo("cookie");
assertThat(appConfig.impressum()).isEqualTo("impressum");
assertThat(appConfig.accessibilityStatement()).isEqualTo("a11y");
}
@Test
@DisplayName("analyticsPrompt true when enableAnalytics null")
void analyticsPrompt() {
applicationProperties.getSystem().setEnableAnalytics(null);
assertThat(appConfig.analyticsPrompt()).isTrue();
applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE);
assertThat(appConfig.analyticsPrompt()).isFalse();
}
@Test
@DisplayName("analyticsEnabled true when premium enabled regardless of system flag")
void analyticsEnabledViaPremium() {
applicationProperties.getPremium().setEnabled(true);
assertThat(appConfig.analyticsEnabled()).isTrue();
}
@Test
@DisplayName("analyticsEnabled reflects system flag when premium disabled")
void analyticsEnabledViaSystem() {
applicationProperties.getPremium().setEnabled(false);
applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE);
assertThat(appConfig.analyticsEnabled()).isTrue();
applicationProperties.getSystem().setEnableAnalytics(Boolean.FALSE);
assertThat(appConfig.analyticsEnabled()).isFalse();
}
@Test
@DisplayName("scarf and posthog beans reflect derived flags")
void scarfAndPosthog() {
applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE);
applicationProperties.getSystem().setEnableScarf(Boolean.TRUE);
applicationProperties.getSystem().setEnablePosthog(Boolean.TRUE);
assertThat(appConfig.scarfEnabled()).isTrue();
assertThat(appConfig.posthogEnabled()).isTrue();
}
@Test
@DisplayName("uuid bean returns generated UUID")
void uuidBean() {
applicationProperties.getAutomaticallyGenerated().setUUID("abc-123");
assertThat(appConfig.uuid()).isEqualTo("abc-123");
}
@Test
@DisplayName("typed config beans return live nested instances")
void typedConfigBeans() {
assertThat(appConfig.security()).isSameAs(applicationProperties.getSecurity());
assertThat(appConfig.oAuth2())
.isSameAs(applicationProperties.getSecurity().getOauth2());
assertThat(appConfig.premium()).isSameAs(applicationProperties.getPremium());
assertThat(appConfig.system()).isSameAs(applicationProperties.getSystem());
assertThat(appConfig.datasource())
.isSameAs(applicationProperties.getSystem().getDatasource());
}
}
@Nested
@DisplayName("Profile-default and environment beans")
class ProfileAndEnvBeans {
@Test
@DisplayName("default-profile license beans return community defaults")
void licenseDefaults() {
assertThat(appConfig.runningProOrHigher()).isFalse();
assertThat(appConfig.runningEnterprise()).isFalse();
assertThat(appConfig.licenseType()).isEqualTo("NORMAL");
}
@Test
@DisplayName("activeSecurity reflects classpath presence of SecurityConfiguration")
void activeSecurity() {
// Just exercise the branch; result depends on classpath, assert it does not throw.
boolean present = appConfig.missingActiveSecurity();
assertThat(present).isIn(true, false);
}
@Test
@DisplayName("rateLimit parses system property")
void rateLimitProperty() {
String prev = System.getProperty("rateLimit");
try {
System.setProperty("rateLimit", "true");
assertThat(appConfig.rateLimit()).isTrue();
} finally {
if (prev == null) {
System.clearProperty("rateLimit");
} else {
System.setProperty("rateLimit", prev);
}
}
}
@Test
@DisplayName("runningInDocker false outside container")
void runningInDocker() {
// CI/test host is not a container with /.dockerenv.
assertThat(appConfig.runningInDocker()).isFalse();
}
@Test
@DisplayName("configDirMounted defaults to true when not in docker")
void configDirMounted() {
assertThat(appConfig.isRunningInDockerWithConfig()).isTrue();
}
@Test
@DisplayName("directoryFilter accepts files and rejects processing dirs")
void directoryFilter(@org.junit.jupiter.api.io.TempDir Path tempDir) throws Exception {
Predicate<Path> filter = appConfig.processOnlyFiles();
Path file = Files.createFile(tempDir.resolve("a.txt"));
Path normalDir = Files.createDirectory(tempDir.resolve("normal"));
Path processingDir = Files.createDirectory(tempDir.resolve("processing"));
assertThat(filter.test(file)).isTrue();
assertThat(filter.test(normalDir)).isTrue();
assertThat(filter.test(processingDir)).isFalse();
}
@Test
@DisplayName("machineType returns Server-jar in plain test environment")
void machineTypeServerJar() {
assertThat(appConfig.determineMachineType()).isEqualTo("Server-jar");
}
@Test
@DisplayName("machineType returns a Client-* variant when BROWSER_OPEN set")
void machineTypeClient() {
env.setProperty("BROWSER_OPEN", "true");
assertThat(appConfig.determineMachineType()).startsWith("Client-");
}
}
}
@@ -0,0 +1,178 @@
package stirling.software.common.configuration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mockStatic;
import java.io.FileNotFoundException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.MockedStatic;
import org.snakeyaml.engine.v2.api.LoadSettings;
import stirling.software.common.util.YamlHelper;
class ConfigInitializerMoreTest {
private static final LoadSettings LOAD_SETTINGS =
LoadSettings.builder()
.setUseMarks(true)
.setMaxAliasesForCollections(Integer.MAX_VALUE)
.setAllowRecursiveKeys(true)
.setParseComments(true)
.build();
// Template after the enterpriseEdition -> premium rename.
private static final String PREMIUM_TEMPLATE =
"""
premium:
enabled: false
key: 0000
proFeatures:
ssoAutoLogin: false
customMetadata:
autoUpdateMetadata: false
author: username
creator: Stirling-PDF
producer: Stirling-PDF
""";
@Nested
@DisplayName("migrateEnterpriseEditionToPremium")
class EnterpriseMigration {
@Test
@DisplayName("carries legacy enterpriseEdition values forward into premium block")
void migratesLegacyEnterpriseValues() throws Exception {
String legacy =
"""
enterpriseEdition:
enabled: true
key: ABC-123
SSOAutoLogin: true
CustomMetadata:
autoUpdateMetadata: true
author: alice
creator: bob
producer: carol
""";
YamlHelper template = new YamlHelper(LOAD_SETTINGS, PREMIUM_TEMPLATE);
YamlHelper existing = new YamlHelper(LOAD_SETTINGS, legacy);
invokeMigrate(existing, template);
assertThat(template.getValueByExactKeyPath("premium", "enabled")).isEqualTo("true");
assertThat(template.getValueByExactKeyPath("premium", "key")).isEqualTo("ABC-123");
assertThat(template.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin"))
.isEqualTo("true");
assertThat(
template.getValueByExactKeyPath(
"premium",
"proFeatures",
"customMetadata",
"autoUpdateMetadata"))
.isEqualTo("true");
assertThat(
template.getValueByExactKeyPath(
"premium", "proFeatures", "customMetadata", "author"))
.isEqualTo("alice");
assertThat(
template.getValueByExactKeyPath(
"premium", "proFeatures", "customMetadata", "creator"))
.isEqualTo("bob");
assertThat(
template.getValueByExactKeyPath(
"premium", "proFeatures", "customMetadata", "producer"))
.isEqualTo("carol");
}
@Test
@DisplayName("no legacy enterpriseEdition block leaves template defaults intact")
void noLegacyKeysIsNoOp() throws Exception {
String noEnterprise =
"""
security:
enableLogin: false
""";
YamlHelper template = new YamlHelper(LOAD_SETTINGS, PREMIUM_TEMPLATE);
YamlHelper existing = new YamlHelper(LOAD_SETTINGS, noEnterprise);
invokeMigrate(existing, template);
assertThat(template.getValueByExactKeyPath("premium", "enabled")).isEqualTo("false");
assertThat(
template.getValueByExactKeyPath(
"premium", "proFeatures", "customMetadata", "author"))
.isEqualTo("username");
}
private void invokeMigrate(YamlHelper yaml, YamlHelper template) throws Exception {
var method =
ConfigInitializer.class.getDeclaredMethod(
"migrateEnterpriseEditionToPremium",
YamlHelper.class,
YamlHelper.class);
method.setAccessible(true);
method.invoke(new ConfigInitializer(), yaml, template);
}
}
@Nested
@DisplayName("ensureConfigExists - create branch (template absent on common classpath)")
class EnsureConfigCreateBranch {
@Test
@DisplayName("no settings file -> attempts create, fails fast when template missing")
void createWithoutTemplateThrows(@TempDir Path tempDir) throws Exception {
Path settings = tempDir.resolve("configs").resolve("settings.yml");
Path custom = tempDir.resolve("configs").resolve("custom_settings.yml");
try (MockedStatic<InstallationPathConfig> mocked =
mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getSettingsPath)
.thenReturn(settings.toString());
mocked.when(InstallationPathConfig::getCustomSettingsPath)
.thenReturn(custom.toString());
// settings.yml.template is packaged in the core module, not common, so the
// create branch must surface a FileNotFoundException here.
assertThatThrownBy(() -> new ConfigInitializer().ensureConfigExists())
.isInstanceOf(FileNotFoundException.class);
}
}
@Test
@DisplayName("short existing settings file is backed up before recreate attempt")
void shortFileIsBackedUp(@TempDir Path tempDir) throws Exception {
Path configDir = Files.createDirectories(tempDir.resolve("configs"));
Path settings = configDir.resolve("settings.yml");
Path custom = configDir.resolve("custom_settings.yml");
// Fewer than MIN_SETTINGS_FILE_LINES (31) lines triggers the recreate path.
Files.writeString(settings, "a: 1\nb: 2\n");
try (MockedStatic<InstallationPathConfig> mocked =
mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getSettingsPath)
.thenReturn(settings.toString());
mocked.when(InstallationPathConfig::getCustomSettingsPath)
.thenReturn(custom.toString());
assertThatThrownBy(() -> new ConfigInitializer().ensureConfigExists())
.isInstanceOf(FileNotFoundException.class);
}
// Original was moved to a timestamped .bak before the failed recreate.
try (Stream<Path> files = Files.list(configDir)) {
assertThat(files.anyMatch(p -> p.getFileName().toString().contains(".bak")))
.isTrue();
}
assertThat(Files.exists(settings)).isFalse();
}
}
}
@@ -0,0 +1,244 @@
package stirling.software.common.pdf;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.jpdfium.text.PageText;
import stirling.software.jpdfium.text.Table;
import stirling.software.jpdfium.text.TextChar;
import stirling.software.jpdfium.text.TextLine;
import stirling.software.jpdfium.text.TextWord;
/**
* Gap-filling tests for {@link PdfMarkdownConverter} not covered by {@link
* PdfMarkdownConverterTest}: the visible-for-testing column-range detector across a range of
* geometries, the package-private extraction helpers, and the full conversion of the wrapped-cell
* fixture (only run under a disabled accuracy test in the sibling suite).
*/
class PdfMarkdownConverterMoreTest {
@TempDir Path tmp;
// ---- helpers ------------------------------------------------------------
/** A word occupying [x, x+width] on baseline y; chars are synthetic so text length is real. */
private static TextWord word(String text, float x, float width) {
List<TextChar> chars = new ArrayList<>();
for (int i = 0; i < text.length(); i++) {
chars.add(
new TextChar(
i,
text.charAt(i),
x,
0f,
width / Math.max(1, text.length()),
10f,
"Helvetica",
10f));
}
return new TextWord(chars, x, 0f, width, 10f);
}
/** A single-line row built from the given words, spanning their full x-range. */
private static TextLine row(float y, TextWord... words) {
float minX = Float.MAX_VALUE;
float maxX = -Float.MAX_VALUE;
for (TextWord w : words) {
minX = Math.min(minX, w.x());
maxX = Math.max(maxX, w.x() + w.width());
}
return new TextLine(List.of(words), minX, y, maxX - minX, 10f);
}
/** Copies a classpath fixture into the temp dir and returns its path. */
private Path fixture(String name) throws IOException {
Path dest = tmp.resolve(name);
try (InputStream in = getClass().getResourceAsStream("/pdf-ingestion-fixtures/" + name)) {
assertThat(in).as("fixture on classpath: " + name).isNotNull();
Files.copy(in, dest);
}
return dest;
}
// ---- findColumnRangesFromLines -----------------------------------------
@Nested
@DisplayName("findColumnRangesFromLines")
class ColumnRanges {
@Test
@DisplayName("two well-separated bands are detected as two columns")
void twoColumns() {
List<TextLine> rows = new ArrayList<>();
for (int r = 0; r < 4; r++) {
float y = 400f - r * 12f;
rows.add(row(y, word("left", 50f, 40f), word("right", 190f, 40f)));
}
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
assertThat(cols).hasSize(2);
// First band starts near 50, second near 190.
assertThat(cols.get(0)[0]).isLessThan(cols.get(1)[0]);
}
@Test
@DisplayName("two bands within a narrow gutter merge into one column")
void narrowGutterMerges() {
List<TextLine> rows = new ArrayList<>();
for (int r = 0; r < 4; r++) {
float y = 400f - r * 12f;
// Gap of ~10pt is far below the merge threshold for 40pt-wide words.
rows.add(row(y, word("aa", 50f, 40f), word("bb", 100f, 40f)));
}
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
assertThat(cols).hasSize(1);
}
@Test
@DisplayName("a single occupied band yields one column (trailing-band flush)")
void singleColumn() {
List<TextLine> rows = new ArrayList<>();
for (int r = 0; r < 3; r++) {
rows.add(row(400f - r * 12f, word("word", 50f, 60f)));
}
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
assertThat(cols).hasSize(1);
assertThat(cols.get(0)[0]).isCloseTo(50f, org.assertj.core.api.Assertions.within(2f));
}
@Test
@DisplayName("rows with no words produce no columns")
void noWordsNoColumns() {
List<TextLine> rows = new ArrayList<>();
for (int r = 0; r < 3; r++) {
rows.add(new TextLine(List.of(), 0f, 400f - r * 12f, 0f, 10f));
}
assertThat(PdfMarkdownConverter.findColumnRangesFromLines(rows)).isEmpty();
}
@Test
@DisplayName("an empty row list produces no columns")
void emptyInput() {
assertThat(PdfMarkdownConverter.findColumnRangesFromLines(List.of())).isEmpty();
}
@Test
@DisplayName("a sparsely-covered band below the support threshold is dropped")
void sparseBandDropped() {
// Five rows fill the left band; only one fills a far-right band, which is below the
// 35%-of-rows support floor and so is not reported as a column.
List<TextLine> rows = new ArrayList<>();
for (int r = 0; r < 5; r++) {
rows.add(row(400f - r * 12f, word("left", 50f, 40f)));
}
rows.add(row(320f, word("left", 50f, 40f), word("rareoutlier", 400f, 60f)));
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
assertThat(cols).hasSize(1);
}
}
// ---- package-private extraction helpers ---------------------------------
@Nested
@DisplayName("extraction helpers")
class ExtractionHelpers {
@Test
@DisplayName("extractAllPageText returns one PageText per page")
void extractAllPageText() throws IOException {
Path pdf = fixture("bordered-table-test_widget.pdf");
try (PdfDocument doc = PdfDocument.open(pdf)) {
List<PageText> pages = new PdfMarkdownConverter().extractAllPageText(doc);
assertThat(pages).isNotNull();
assertThat(pages).hasSize(doc.pageCount());
}
}
@Test
@DisplayName("extractTables returns a non-null list for the first page")
void extractTables() throws IOException {
Path pdf = fixture("bordered-table-test_widget.pdf");
try (PdfDocument doc = PdfDocument.open(pdf)) {
List<Table> tables = new PdfMarkdownConverter().extractTables(doc, 0);
assertThat(tables).isNotNull();
}
}
@Test
@DisplayName("renderTables maps each extracted table to a markdown string")
void renderTables() throws IOException {
Path pdf = fixture("bordered-table-test_widget.pdf");
PdfMarkdownConverter converter = new PdfMarkdownConverter();
try (PdfDocument doc = PdfDocument.open(pdf)) {
List<Table> tables = converter.extractTables(doc, 0);
List<String> rendered = converter.renderTables(tables);
assertThat(rendered).isNotNull();
assertThat(rendered).hasSameSizeAs(tables);
}
}
@Test
@DisplayName("renderTables on an empty table list returns an empty list")
void renderTablesEmpty() {
assertThat(new PdfMarkdownConverter().renderTables(List.of())).isEmpty();
}
}
// ---- full conversion of additional fixtures -----------------------------
@Nested
@DisplayName("convert full pipeline")
class ConvertPipeline {
@Test
@DisplayName("wrapped-cell expense report converts without throwing and yields content")
void wrappedCellFixture() throws IOException {
Path pdf = fixture("wrapped-cell-test_expense-report.pdf");
String md;
try (PdfDocument doc = PdfDocument.open(pdf)) {
md = new PdfMarkdownConverter().convert(doc);
}
assertThat(md).isNotNull();
assertThat(md).isNotBlank();
}
@Test
@DisplayName("converting a fixture twice is deterministic")
void deterministic() throws IOException {
Path pdf = fixture("multi-column-test_lorem.pdf");
String first;
String second;
try (PdfDocument doc = PdfDocument.open(pdf)) {
first = new PdfMarkdownConverter().convert(doc);
}
try (PdfDocument doc = PdfDocument.open(pdf)) {
second = new PdfMarkdownConverter().convert(doc);
}
assertThat(first).isEqualTo(second);
}
@Test
@DisplayName("the many-tables stress fixture converts without throwing")
void manyTablesFixture() throws IOException {
Path pdf = fixture("many-tables-test_stress.pdf");
assertDoesNotThrow(
() -> {
try (PdfDocument doc = PdfDocument.open(pdf)) {
return new PdfMarkdownConverter().convert(doc);
}
});
}
}
}
@@ -0,0 +1,152 @@
package stirling.software.common.pdf;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import stirling.software.jpdfium.text.Table;
/**
* Unit tests for {@link TableRenderer}. Tables are built directly from the {@link Table} record so
* the renderer can be exercised without any PDF parsing, fixtures, or native calls.
*/
class TableRendererTest {
/** Builds a Table from raw rows; geometry is irrelevant to rendering so it is set to zero. */
private static Table table(List<List<String>> rows) {
return new Table(rows, 0f, 0f, 0f, 0f);
}
@Nested
@DisplayName("Degenerate tables")
class Degenerate {
@Test
@DisplayName("zero rows renders the empty string")
void zeroRows() {
assertThat(TableRenderer.render(table(List.of()))).isEmpty();
}
@Test
@DisplayName("single row with one column has no separator and is a plain line")
void singleRowOneColumn() {
String md = TableRenderer.render(table(List.of(List.of("only"))));
assertThat(md).isEqualTo("only");
assertThat(md).doesNotContain("|");
}
@Test
@DisplayName("single row with several columns becomes newline-separated plain lines")
void singleRowManyColumns() {
String md = TableRenderer.render(table(List.of(List.of("a", "b", "c"))));
// No separator row is possible with a single row, so cells are emitted as lines.
assertThat(md).isEqualTo("a\nb\nc");
}
@Test
@DisplayName("single-row cell content is trimmed and escaped")
void singleRowTrimsAndEscapes() {
String md = TableRenderer.render(table(List.of(List.of(" a|b "))));
assertThat(md).isEqualTo("a\\|b");
}
}
@Nested
@DisplayName("GFM rendering")
class GfmRendering {
@Test
@DisplayName("two rows produce a header, a separator and a data row")
void headerSeparatorData() {
String md =
TableRenderer.render(
table(List.of(List.of("Name", "Age"), List.of("Alice", "30"))));
String[] lines = md.split("\n");
assertThat(lines).hasSize(3);
assertThat(lines[0]).startsWith("|").contains("Name").contains("Age");
// Separator row is made only of pipes and dashes.
assertThat(lines[1].chars().allMatch(c -> c == '|' || c == '-')).isTrue();
assertThat(lines[2]).contains("Alice").contains("30");
}
@Test
@DisplayName("column widths grow to fit the widest cell in each column")
void columnWidthsFitContent() {
String md =
TableRenderer.render(
table(
List.of(
List.of("h", "header2"),
List.of("averylongvalue", "x"))));
String[] lines = md.split("\n");
// Every rendered row (header, separator, data) is the same total width.
int width = lines[0].length();
for (String line : lines) {
assertThat(line.length()).isEqualTo(width);
}
}
@Test
@DisplayName("minimum column width of three dashes is honoured for tiny cells")
void minimumWidthThree() {
String md = TableRenderer.render(table(List.of(List.of("a", "b"), List.of("c", "d"))));
String separator = md.split("\n")[1];
// Each column is padded to a minimum of 3, fenced by a dash either side: |-----|-----|.
assertThat(separator).isEqualTo("|-----|-----|");
}
@Test
@DisplayName("pipe characters in cells are escaped in every rendered row")
void escapesPipes() {
String md =
TableRenderer.render(table(List.of(List.of("a|b", "c"), List.of("d", "e|f"))));
// Two literal pipes escaped; the structural pipes are not.
assertThat(md).contains("a\\|b").contains("e\\|f");
}
@Test
@DisplayName("cells are trimmed before measuring and rendering")
void trimsCells() {
String md =
TableRenderer.render(
table(List.of(List.of(" Name ", " Age "), List.of("Al", "30"))));
assertThat(md).contains("| Name").contains("Age ");
assertThat(md).doesNotContain(" Name ");
}
@Test
@DisplayName("three rows emit two data rows after the separator")
void multipleDataRows() {
String md =
TableRenderer.render(
table(
List.of(
List.of("c1", "c2"),
List.of("a", "b"),
List.of("x", "y"))));
String[] lines = md.split("\n");
assertThat(lines).hasSize(4);
assertThat(lines[2]).contains("a").contains("b");
assertThat(lines[3]).contains("x").contains("y");
}
@Test
@DisplayName("a short trailing row is padded out to the column count from asGrid")
void shortRowPaddedByGrid() {
// colCount comes from the first row; a shorter later row is padded with empty cells by
// Table.asGrid, so rendering must not throw and the grid stays rectangular.
String md =
TableRenderer.render(table(List.of(List.of("a", "b", "c"), List.of("only"))));
String[] lines = md.split("\n");
assertThat(lines).hasSize(3);
int width = lines[0].length();
for (String line : lines) {
assertThat(line.length()).isEqualTo(width);
}
}
}
}
@@ -0,0 +1,180 @@
package stirling.software.common.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.io.MemoryUsageSetting;
import org.apache.pdfbox.io.RandomAccessStreamCache.StreamCacheCreateFunction;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
class CustomPDFDocumentFactoryMoreTest {
private CustomPDFDocumentFactory factory;
private byte[] basePdfBytes;
@BeforeEach
void setUp() throws IOException {
factory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
try (InputStream is = getClass().getResourceAsStream("/example.pdf")) {
basePdfBytes = is.readAllBytes();
}
}
@Nested
@DisplayName("null-argument guards")
class NullGuards {
@Test
@DisplayName("each load overload rejects null with IllegalArgumentException")
void nullArguments() {
assertThatThrownBy(() -> factory.load((File) null))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> factory.load((Path) null))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> factory.load((byte[]) null))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> factory.load((InputStream) null))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> factory.load((InputStream) null, "pw"))
.isInstanceOf(IllegalArgumentException.class);
}
}
@Nested
@DisplayName("cache strategy selection (public overload)")
class CacheStrategy {
@Test
@DisplayName("getStreamCacheFunction returns a non-null function for each size band")
void cacheFunctionPerBand() {
StreamCacheCreateFunction small = factory.getStreamCacheFunction(1024);
StreamCacheCreateFunction mixed = factory.getStreamCacheFunction(20L * 1024 * 1024);
StreamCacheCreateFunction large = factory.getStreamCacheFunction(60L * 1024 * 1024);
assertThat(small).isNotNull();
assertThat(mixed).isNotNull();
assertThat(large).isNotNull();
}
}
@Nested
@DisplayName("create and round-trip helpers")
class CreateAndRoundTrip {
@Test
@DisplayName("createNewDocument(MemoryUsageSetting) sets default metadata")
void createWithMemorySetting() throws IOException {
PdfMetadataService svc = mock(PdfMetadataService.class);
CustomPDFDocumentFactory f = new CustomPDFDocumentFactory(svc);
try (PDDocument doc = f.createNewDocument(MemoryUsageSetting.setupMainMemoryOnly())) {
assertThat(doc).isNotNull();
verify(svc).setDefaultMetadata(doc);
}
}
@Test
@DisplayName("loadToBytes(byte[]) round-trips a loadable PDF")
void loadToBytesFromArray() throws IOException {
byte[] out = factory.loadToBytes(basePdfBytes);
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(out)) {
assertThat(doc.getNumberOfPages()).isPositive();
}
}
@Test
@DisplayName("createNewDocumentBasedOnOldDocument(byte[]) produces a fresh document")
void newDocFromOldBytes() throws IOException {
try (PDDocument doc = factory.createNewDocumentBasedOnOldDocument(basePdfBytes)) {
assertThat(doc).isNotNull();
}
}
@Test
@DisplayName("createNewDocumentBasedOnOldDocument(File) produces a fresh document")
void newDocFromOldFile(@TempDir Path tempDir) throws IOException {
File f = Files.write(tempDir.resolve("old.pdf"), basePdfBytes).toFile();
try (PDDocument doc = factory.createNewDocumentBasedOnOldDocument(f)) {
assertThat(doc).isNotNull();
}
}
}
@Nested
@DisplayName("read-only and password handling")
class ReadOnlyAndPassword {
@Test
@DisplayName("read-only load from file skips post-processing")
void readOnlyFromFile(@TempDir Path tempDir) throws IOException {
PdfMetadataService svc = mock(PdfMetadataService.class);
CustomPDFDocumentFactory f = new CustomPDFDocumentFactory(svc);
File file = Files.write(tempDir.resolve("ro.pdf"), basePdfBytes).toFile();
try (PDDocument doc = f.load(file, true)) {
assertThat(doc).isNotNull();
org.mockito.Mockito.verify(svc, org.mockito.Mockito.never())
.setDefaultMetadata(org.mockito.ArgumentMatchers.any());
}
}
@Test
@DisplayName("encrypted PDF is decrypted on the default (non-read-only) load path")
void encryptedPdfDecrypted() throws IOException {
byte[] encrypted = buildEncryptedPdf("ownerpw", "userpw");
// load(InputStream, password) drives removePassword + setAllSecurityToBeRemoved so the
// returned document can be re-saved with no password set.
byte[] decryptedSaved;
try (PDDocument doc =
factory.load(new ByteArrayInputStream(encrypted), "userpw", false)) {
assertThat(doc.getNumberOfPages()).isPositive();
decryptedSaved = factory.saveToBytes(doc);
}
// Re-loading with no password proves security was stripped.
try (PDDocument reloaded = org.apache.pdfbox.Loader.loadPDF(decryptedSaved)) {
assertThat(reloaded.isEncrypted()).isFalse();
}
}
@Test
@DisplayName("MultipartFile with positive small size uses byte[] path")
void smallMultipartLoadsViaBytes() throws IOException {
MockMultipartFile multipart =
new MockMultipartFile(
"file", "s.pdf", MediaType.APPLICATION_PDF_VALUE, basePdfBytes);
try (PDDocument doc = factory.load(multipart)) {
assertThat(doc.getNumberOfPages()).isPositive();
}
}
}
private static byte[] buildEncryptedPdf(String ownerPw, String userPw) throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
AccessPermission ap = new AccessPermission();
StandardProtectionPolicy spp = new StandardProtectionPolicy(ownerPw, userPw, ap);
spp.setEncryptionKeyLength(128);
doc.protect(spp);
ByteArrayOutputStream out = new ByteArrayOutputStream();
doc.save(out);
return out.toByteArray();
}
}
}
@@ -0,0 +1,152 @@
package stirling.software.common.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.core.io.FileSystemResource;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import stirling.software.common.cluster.inprocess.LocalDiskFileStore;
import stirling.software.common.service.FileStorage.StoredFile;
class FileStorageMoreTest {
@TempDir Path storageDir;
private FileStorage fileStorage;
@BeforeEach
void setUp() {
fileStorage =
new FileStorage(
mock(FileOrUploadService.class),
new LocalDiskFileStore(storageDir.toString()),
Optional.empty());
}
@Nested
@DisplayName("storeInputStream / getFileSize / retrieveInputStream")
class StreamAndSize {
@Test
@DisplayName("storeInputStream returns id and exact byte size")
void storeInputStreamReturnsSize() throws IOException {
byte[] payload = "twelve bytes".getBytes(StandardCharsets.UTF_8);
StoredFile stored =
fileStorage.storeInputStream(new ByteArrayInputStream(payload), "in.bin");
assertThat(stored.fileId()).isNotBlank();
assertThat(stored.size()).isEqualTo(payload.length);
assertThat(fileStorage.getFileSize(stored.fileId())).isEqualTo(payload.length);
}
@Test
@DisplayName("retrieveInputStream yields the stored content")
void retrieveInputStreamContent() throws IOException {
byte[] payload = "stream-me".getBytes(StandardCharsets.UTF_8);
String id = fileStorage.storeBytes(payload, "s.bin");
try (InputStream in = fileStorage.retrieveInputStream(id)) {
assertThat(in.readAllBytes()).isEqualTo(payload);
}
}
}
@Nested
@DisplayName("storeFile fast path")
class FastPath {
@Test
@DisplayName("file-backed MultipartFile is stored via the Resource fast path")
void fileBackedResourceStored(@TempDir Path src) throws IOException {
byte[] payload = "file-backed-content".getBytes(StandardCharsets.UTF_8);
Path onDisk = Files.write(src.resolve("upload.pdf"), payload);
// A MultipartFile whose getResource() reports isFile()=true exercises the
// file-to-file copy branch in storeFile.
MultipartFile multipart =
new MockMultipartFile(
"file", "upload.pdf", MediaType.APPLICATION_PDF_VALUE, payload) {
@Override
public org.springframework.core.io.Resource getResource() {
return new FileSystemResource(onDisk);
}
};
String id = fileStorage.storeFile(multipart);
assertThat(id).isNotBlank();
assertThat(fileStorage.retrieveBytes(id)).isEqualTo(payload);
}
@Test
@DisplayName("in-memory MultipartFile falls back to the stream copy path")
void inMemoryFallback() throws IOException {
byte[] payload = "memory-content".getBytes(StandardCharsets.UTF_8);
MultipartFile multipart =
new MockMultipartFile(
"file", "m.pdf", MediaType.APPLICATION_PDF_VALUE, payload);
String id = fileStorage.storeFile(multipart);
assertThat(fileStorage.retrieveBytes(id)).isEqualTo(payload);
}
}
@Nested
@DisplayName("storeFromStreamingBody")
class StreamingBody {
@Test
@DisplayName("happy path streams body to storage")
void happyPath() throws IOException {
byte[] payload = "streamed-body-bytes".getBytes(StandardCharsets.UTF_8);
StreamingResponseBody body = out -> out.write(payload);
String id = fileStorage.storeFromStreamingBody(body, "body.bin");
assertThat(fileStorage.retrieveBytes(id)).isEqualTo(payload);
}
@Test
@DisplayName("writer IOException propagates and leaves no lingering file")
void writerErrorPropagatesAndCleansUp() throws IOException {
long before = countFiles();
StreamingResponseBody body =
out -> {
out.write("partial".getBytes(StandardCharsets.UTF_8));
throw new IOException("boom mid-write");
};
assertThatThrownBy(() -> fileStorage.storeFromStreamingBody(body, "bad.bin"))
.isInstanceOf(IOException.class);
assertThat(countFiles()).isEqualTo(before);
}
@Test
@DisplayName("unchecked writer failure is wrapped as IOException")
void uncheckedWriterErrorWrapped() {
StreamingResponseBody body =
out -> {
throw new IllegalStateException("unchecked boom");
};
assertThatThrownBy(() -> fileStorage.storeFromStreamingBody(body, "bad2.bin"))
.isInstanceOf(IOException.class);
}
private long countFiles() throws IOException {
try (var s = Files.list(storageDir)) {
return s.count();
}
}
}
}
@@ -0,0 +1,492 @@
package stirling.software.common.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import stirling.software.common.model.job.JobResponse;
import stirling.software.common.util.ExceptionUtils;
/** Additional coverage for JobExecutorService branches not exercised by JobExecutorServiceTest. */
@ExtendWith(MockitoExtension.class)
class JobExecutorServiceMoreTest {
private JobExecutorService service;
@Mock private TaskManager taskManager;
@Mock private FileStorage fileStorage;
@Mock private ResourceMonitor resourceMonitor;
@Mock private JobQueue jobQueue;
@BeforeEach
void setUp() {
// request is null on purpose to exercise the request==null guard.
service =
new JobExecutorService(
taskManager, fileStorage, null, resourceMonitor, jobQueue, 30000L, "30m");
}
/** Concrete validation exception so we can drive the BaseValidationException rethrow branch. */
private static class TestValidationException extends ExceptionUtils.BaseValidationException {
TestValidationException(String message) {
super(message, "E999");
}
}
/** Concrete app exception so we can drive the BaseAppException rethrow branch. */
private static class TestAppException extends ExceptionUtils.BaseAppException {
TestAppException(String message) {
super(message, null, "E998");
}
}
/** Bean exposing getFileId/getOriginalFilename/getContentType for the reflection branch. */
public static class FileIdBean {
private final String fileId;
private final String originalFilename;
private final String contentType;
FileIdBean(String fileId, String originalFilename, String contentType) {
this.fileId = fileId;
this.originalFilename = originalFilename;
this.contentType = contentType;
}
public String getFileId() {
return fileId;
}
public String getOriginalFilename() {
return originalFilename;
}
public String getContentType() {
return contentType;
}
@Override
public String toString() {
return "FileIdBean{fileId=" + fileId + "}";
}
}
@Nested
@DisplayName("synchronous error mapping")
class SyncErrors {
@Test
@DisplayName("IllegalArgumentException is rethrown, not wrapped in a 500 body")
void illegalArgumentRethrown() {
Supplier<Object> work =
() -> {
throw new IllegalArgumentException("bad input");
};
assertThatThrownBy(() -> service.runJobGeneric(false, work))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("bad input");
}
@Test
@DisplayName("a cause of BaseValidationException is rethrown")
void validationCauseRethrown() {
Supplier<Object> work =
() -> {
throw new RuntimeException(new TestValidationException("invalid"));
};
assertThatThrownBy(() -> service.runJobGeneric(false, work))
.isInstanceOf(RuntimeException.class)
.hasCauseInstanceOf(ExceptionUtils.BaseValidationException.class);
}
@Test
@DisplayName("a cause of BaseAppException is rethrown")
void appCauseRethrown() {
Supplier<Object> work =
() -> {
throw new RuntimeException(new TestAppException("app error"));
};
assertThatThrownBy(() -> service.runJobGeneric(false, work))
.isInstanceOf(RuntimeException.class)
.hasCauseInstanceOf(ExceptionUtils.BaseAppException.class);
}
}
@Nested
@DisplayName("synchronous result handling")
class SyncResults {
@Test
@DisplayName("byte[] result becomes a PDF attachment response")
void byteArrayBecomesAttachment() {
byte[] payload = "pdf-bytes".getBytes(StandardCharsets.UTF_8);
ResponseEntity<?> response = service.runJobGeneric(false, () -> payload);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isEqualTo(payload);
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_PDF);
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
.contains("result.pdf");
}
@Test
@DisplayName("MultipartFile result is streamed back with its own content type")
void multipartBecomesResponse() {
MultipartFile file =
new MockMultipartFile(
"f", "orig.txt", MediaType.TEXT_PLAIN_VALUE, "hi".getBytes());
ResponseEntity<?> response = service.runJobGeneric(false, () -> file);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_PLAIN);
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
.contains("orig.txt");
}
@Test
@DisplayName("a ResponseEntity result is returned verbatim")
void responseEntityReturnedVerbatim() {
ResponseEntity<String> inner = ResponseEntity.status(HttpStatus.ACCEPTED).body("ok");
ResponseEntity<?> response = service.runJobGeneric(false, () -> inner);
assertThat(response).isSameAs(inner);
}
}
@Nested
@DisplayName("asynchronous error handling")
class AsyncErrors {
@Test
@DisplayName("a thrown exception is recorded via TaskManager.setError")
void asyncErrorRecorded() {
Supplier<Object> work =
() -> {
throw new RuntimeException("async boom");
};
ResponseEntity<?> response = service.runJobGeneric(true, work);
assertThat(response.getBody()).isInstanceOf(JobResponse.class);
verify(taskManager, timeout(5000)).setError(anyString(), eq("async boom"));
}
@Test
@DisplayName("a job that exceeds its timeout is recorded as timed out")
void asyncTimeoutRecorded() {
Supplier<Object> work =
() -> {
long start = System.nanoTime();
while (System.nanoTime() - start < 200_000_000L) {
// busy wait beyond the 1ms timeout
}
return "late";
};
// 1ms custom timeout, async, non-queueable.
ResponseEntity<?> response = service.runJobGeneric(true, work, 1L, false, 10);
assertThat(response.getBody()).isInstanceOf(JobResponse.class);
verify(taskManager, timeout(5000)).setError(anyString(), eq("Job timed out"));
}
}
@Nested
@DisplayName("processJobResult branches (via async execution)")
class ProcessJobResult {
@Test
@DisplayName("raw byte[] result is stored and recorded as a file")
void rawBytesStored() throws Exception {
byte[] payload = "raw".getBytes(StandardCharsets.UTF_8);
when(fileStorage.storeBytes(any(byte[].class), eq("result.pdf")))
.thenReturn("bytes-id");
service.runJobGeneric(true, () -> payload);
verify(fileStorage, timeout(5000)).storeBytes(any(byte[].class), eq("result.pdf"));
verify(taskManager, timeout(5000))
.setFileResult(
anyString(),
eq("bytes-id"),
eq("result.pdf"),
eq(MediaType.APPLICATION_PDF_VALUE));
verify(taskManager, timeout(5000)).setComplete(anyString());
}
@Test
@DisplayName("ResponseEntity<byte[]> is stored with the filename from headers")
void responseEntityBytesStored() throws Exception {
byte[] payload = "rebytes".getBytes(StandardCharsets.UTF_8);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
headers.setContentDisposition(
ContentDisposition.formData().name("a").filename("out.pdf").build());
Supplier<Object> work = () -> new ResponseEntity<>(payload, headers, HttpStatus.OK);
when(fileStorage.storeBytes(any(byte[].class), eq("out.pdf"))).thenReturn("re-id");
service.runJobGeneric(true, work);
verify(taskManager, timeout(5000))
.setFileResult(
anyString(),
eq("re-id"),
eq("out.pdf"),
eq(MediaType.APPLICATION_PDF_VALUE));
}
@Test
@DisplayName("ResponseEntity<StreamingResponseBody> is stored via storeFromStreamingBody")
void responseEntityStreamingStored() throws Exception {
StreamingResponseBody body = out -> out.write("stream".getBytes());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
Supplier<Object> work = () -> new ResponseEntity<>(body, headers, HttpStatus.OK);
when(fileStorage.storeFromStreamingBody(any(StreamingResponseBody.class), anyString()))
.thenReturn("stream-id");
service.runJobGeneric(true, work);
verify(fileStorage, timeout(5000))
.storeFromStreamingBody(any(StreamingResponseBody.class), eq("result.pdf"));
verify(taskManager, timeout(5000))
.setFileResult(anyString(), eq("stream-id"), eq("result.pdf"), anyString());
}
@Test
@DisplayName("ResponseEntity body exposing getFileId is recorded via reflection")
void responseEntityFileIdBean() {
FileIdBean bean = new FileIdBean("bean-file", "bean.pdf", "text/custom");
Supplier<Object> work = () -> ResponseEntity.ok(bean);
service.runJobGeneric(true, work);
verify(taskManager, timeout(5000))
.setFileResult(anyString(), eq("bean-file"), eq("bean.pdf"), eq("text/custom"));
verify(taskManager, timeout(5000)).setComplete(anyString());
}
@Test
@DisplayName("plain ResponseEntity body without fileId is stored as a generic result")
void responseEntityPlainBody() {
Supplier<Object> work = () -> ResponseEntity.ok("plain-string");
service.runJobGeneric(true, work);
verify(taskManager, timeout(5000)).setResult(anyString(), eq("plain-string"));
verify(taskManager, timeout(5000)).setComplete(anyString());
}
@Test
@DisplayName("MultipartFile result is stored via storeFile")
void multipartStored() throws Exception {
MultipartFile file =
new MockMultipartFile(
"f", "m.pdf", MediaType.APPLICATION_PDF_VALUE, "m".getBytes());
when(fileStorage.storeFile(any(MultipartFile.class))).thenReturn("mp-id");
service.runJobGeneric(true, () -> file);
verify(taskManager, timeout(5000))
.setFileResult(
anyString(),
eq("mp-id"),
eq("m.pdf"),
eq(MediaType.APPLICATION_PDF_VALUE));
}
@Test
@DisplayName("plain object result exposing getFileId is recorded via reflection")
void plainObjectFileIdBean() {
FileIdBean bean = new FileIdBean("plain-bean", "p.pdf", "app/p");
service.runJobGeneric(true, () -> bean);
verify(taskManager, timeout(5000))
.setFileResult(anyString(), eq("plain-bean"), eq("p.pdf"), eq("app/p"));
}
@Test
@DisplayName("a generic non-file object is stored via setResult")
void genericObjectStored() {
service.runJobGeneric(true, () -> "just-text");
verify(taskManager, timeout(5000)).setResult(anyString(), eq("just-text"));
}
@Test
@DisplayName("a storage failure is recorded as an error on the task")
void storageFailureRecordsError() throws Exception {
byte[] payload = "x".getBytes(StandardCharsets.UTF_8);
when(fileStorage.storeBytes(any(byte[].class), anyString()))
.thenThrow(new java.io.IOException("disk full"));
service.runJobGeneric(true, () -> payload);
verify(taskManager, timeout(5000))
.setError(anyString(), org.mockito.ArgumentMatchers.contains("disk full"));
}
}
@Nested
@DisplayName("queued execution")
class QueuedExecution {
@Test
@DisplayName("queued wrapped work stores its result through processJobResult on success")
void queuedWorkSuccess() {
when(resourceMonitor.shouldQueueJob(80)).thenReturn(true);
// Capture the wrapped supplier so we can run it as the queue would.
ArgumentCaptor<Supplier<Object>> workCaptor = ArgumentCaptor.forClass(Supplier.class);
when(jobQueue.queueJob(anyString(), eq(80), workCaptor.capture(), anyLong()))
.thenReturn(new CompletableFuture<>());
ResponseEntity<?> response =
service.runJobGeneric(true, () -> "queued-ok", 5000, true, 80);
assertThat(response.getBody()).isInstanceOf(JobResponse.class);
// Execute the wrapped work and assert it routed the result to TaskManager.
Object result = workCaptor.getValue().get();
assertThat(result).isEqualTo("queued-ok");
verify(taskManager).setResult(anyString(), eq("queued-ok"));
verify(taskManager).setComplete(anyString());
}
@Test
@DisplayName("queued wrapped work records and rethrows on failure")
void queuedWorkFailure() {
when(resourceMonitor.shouldQueueJob(80)).thenReturn(true);
ArgumentCaptor<Supplier<Object>> workCaptor = ArgumentCaptor.forClass(Supplier.class);
when(jobQueue.queueJob(anyString(), eq(80), workCaptor.capture(), anyLong()))
.thenReturn(new CompletableFuture<>());
Supplier<Object> failing =
() -> {
throw new RuntimeException("queued-boom");
};
service.runJobGeneric(true, failing, 5000, true, 80);
assertThatThrownBy(() -> workCaptor.getValue().get())
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("queued-boom");
verify(taskManager).setError(anyString(), eq("queued-boom"));
}
@Test
@DisplayName("a job is not queued when it is synchronous even if queueable")
void syncJobNeverQueued() {
// queueable=true but async=false -> shouldQueue is false, runs inline.
ResponseEntity<?> response = service.runJobGeneric(false, () -> "inline", 0, true, 90);
assertThat(response.getBody()).isEqualTo("inline");
verify(jobQueue, org.mockito.Mockito.never())
.queueJob(anyString(), anyInt(), any(), anyLong());
}
}
@Nested
@DisplayName("job ownership scoping")
class JobOwnership {
@Test
@DisplayName("scoped job key and owner come from JobOwnershipService when present")
void scopedKeyUsed() {
JobOwnershipService ownership = org.mockito.Mockito.mock(JobOwnershipService.class);
when(ownership.createScopedJobKey(anyString())).thenReturn("user1:scoped");
lenient().when(ownership.getCurrentUserId()).thenReturn(Optional.of("user1"));
ReflectionTestUtils.setField(service, "jobOwnershipService", ownership);
ResponseEntity<?> response = service.runJobGeneric(true, () -> "owned");
JobResponse<?> jobResponse = (JobResponse<?>) response.getBody();
assertThat(jobResponse.getJobId()).isEqualTo("user1:scoped");
verify(taskManager).createTask("user1:scoped");
}
}
@Nested
@DisplayName("session timeout parsing")
class SessionTimeoutParsing {
private long parse(String value) {
JobExecutorService s =
new JobExecutorService(
taskManager,
fileStorage,
null,
resourceMonitor,
jobQueue,
999_999_999L,
value);
return (long) ReflectionTestUtils.getField(s, "effectiveTimeoutMs");
}
@Test
@DisplayName("seconds, hours and days units are parsed")
void parsesUnits() {
assertThat(parse("45s")).isEqualTo(45_000L);
assertThat(parse("2h")).isEqualTo(2L * 60 * 60 * 1000);
assertThat(parse("1d")).isEqualTo(24L * 60 * 60 * 1000);
}
@Test
@DisplayName("an unrecognised unit defaults to minutes")
void unknownUnitDefaultsToMinutes() {
assertThat(parse("5x")).isEqualTo(5L * 60 * 1000);
}
@Test
@DisplayName("null/empty and unparseable values fall back to 30 minutes")
void fallbackToThirtyMinutes() {
long thirtyMin = 30L * 60 * 1000;
assertThat(parse("")).isEqualTo(thirtyMin);
assertThat(parse("garbage")).isEqualTo(thirtyMin);
}
}
@Nested
@DisplayName("sync timeout")
class SyncTimeout {
@Test
@DisplayName("a synchronous job that exceeds its timeout returns a 500 with a timeout body")
void syncTimeoutReturns500() {
Supplier<Object> work =
() -> {
long start = System.nanoTime();
while (System.nanoTime() - start < 200_000_000L) {
// busy wait beyond 1ms timeout
}
return "late";
};
ResponseEntity<?> response = service.runJobGeneric(false, work, 1L);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
@SuppressWarnings("unchecked")
Map<String, String> body = (Map<String, String>) response.getBody();
assertThat(body.get("error")).contains("timed out");
}
}
}
@@ -0,0 +1,410 @@
package stirling.software.common.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.service.ResourceMonitor.ResourceStatus;
/** Additional coverage for JobQueue branches not exercised by JobQueueTest. */
@ExtendWith(MockitoExtension.class)
class JobQueueMoreTest {
private JobQueue jobQueue;
@Mock private ResourceMonitor resourceMonitor;
private final AtomicReference<ResourceStatus> statusRef =
new AtomicReference<>(ResourceStatus.OK);
@BeforeEach
void setUp() {
lenient()
.when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt()))
.thenReturn(10);
lenient().when(resourceMonitor.getCurrentStatus()).thenReturn(statusRef);
jobQueue = new JobQueue(resourceMonitor);
}
private void invokeProcessQueue() {
ReflectionTestUtils.invokeMethod(jobQueue, "processQueue");
}
// Bounded wait: block up to 5s for the queued job's future to settle on the executor.
private static void awaitDone(CompletableFuture<?> future) {
try {
future.handle((r, e) -> null).get(5, TimeUnit.SECONDS);
} catch (Exception e) {
throw new AssertionError("future did not complete within 5s", e);
}
}
@Nested
@DisplayName("SmartLifecycle")
class Lifecycle {
@Test
@DisplayName("start/stop toggles running and start is idempotent")
void startStopToggle() {
assertThat(jobQueue.isRunning()).isFalse();
jobQueue.start();
assertThat(jobQueue.isRunning()).isTrue();
// Second start is a no-op (already running).
jobQueue.start();
assertThat(jobQueue.isRunning()).isTrue();
jobQueue.stop();
assertThat(jobQueue.isRunning()).isFalse();
}
@Test
@DisplayName("phase and auto-startup expose lifecycle ordering")
void phaseAndAutoStartup() {
assertThat(jobQueue.getPhase()).isEqualTo(10);
assertThat(jobQueue.isAutoStartup()).isTrue();
}
@Test
@DisplayName("stop completes any still-pending futures exceptionally")
void stopCompletesPendingFutures() {
CompletableFuture<ResponseEntity<?>> future =
jobQueue.queueJob("pending", 50, () -> "x", 1000);
assertThat(future.isDone()).isFalse();
// Drive shutdown without starting the scheduler so no processor races us to the job.
jobQueue.stop();
assertThat(future).isCompletedExceptionally();
}
}
@Nested
@DisplayName("queueJob capacity")
class QueueCapacity {
@Test
@DisplayName("rejects a job when the queue is full")
void rejectsWhenFull() {
// Capacity-1 queue whose timed offer rejects instantly (no 5s block) when full.
BlockingQueue<Object> instaReject =
new LinkedBlockingQueue<>(1) {
@Override
public boolean offer(Object e, long timeout, TimeUnit unit) {
return super.offer(e);
}
};
ReflectionTestUtils.setField(jobQueue, "jobQueue", instaReject);
jobQueue.queueJob("first", 50, () -> "a", 1000);
CompletableFuture<ResponseEntity<?>> rejected =
jobQueue.queueJob("second", 50, () -> "b", 1000);
assertThat(rejected).isCompletedExceptionally();
assertThat(jobQueue.getRejectedJobs()).isEqualTo(1);
assertThat(jobQueue.isJobQueued("second")).isFalse();
}
@Test
@DisplayName("getQueueCapacity reflects remaining capacity plus current size")
void getQueueCapacityReports() {
ReflectionTestUtils.setField(jobQueue, "jobQueue", new LinkedBlockingQueue<>(5));
jobQueue.queueJob("c1", 50, () -> "a", 1000);
assertThat(jobQueue.getQueueCapacity()).isEqualTo(5);
}
}
@Nested
@DisplayName("job position")
class JobPosition {
@Test
@DisplayName("returns 0 for the first queued job and -1 for an unknown job")
void positionAndUnknown() {
jobQueue.queueJob("p1", 50, () -> "a", 1000);
jobQueue.queueJob("p2", 50, () -> "b", 1000);
assertThat(jobQueue.getJobPosition("p1")).isEqualTo(0);
assertThat(jobQueue.getJobPosition("p2")).isEqualTo(1);
assertThat(jobQueue.getJobPosition("missing")).isEqualTo(-1);
}
}
@Nested
@DisplayName("cancelJob")
class CancelJob {
@Test
@DisplayName("returns false when the job id is unknown")
void cancelUnknownReturnsFalse() {
assertThat(jobQueue.cancelJob("nope")).isFalse();
}
}
@Nested
@DisplayName("processQueue")
class ProcessQueue {
@Test
@DisplayName("does nothing when shutting down")
void noopWhenShuttingDown() {
jobQueue.queueJob("s1", 50, () -> "a", 1000);
ReflectionTestUtils.setField(jobQueue, "shuttingDown", true);
invokeProcessQueue();
// Still queued: the shutdown guard returned before polling.
assertThat(jobQueue.isJobQueued("s1")).isTrue();
}
@Test
@DisplayName("delays execution while the system is under critical load")
void delaysUnderCriticalLoad() {
statusRef.set(ResourceStatus.CRITICAL);
jobQueue.queueJob("crit", 50, () -> "a", 1000);
invokeProcessQueue();
// Critical load: job remains queued, nothing executed.
assertThat(jobQueue.isJobQueued("crit")).isTrue();
}
@Test
@DisplayName("executes a queued job and completes its future when resources are OK")
void executesWhenOk() {
statusRef.set(ResourceStatus.OK);
CompletableFuture<ResponseEntity<?>> future =
jobQueue.queueJob("ok", 50, () -> "done", 5000);
invokeProcessQueue();
awaitDone(future);
assertThat(jobQueue.isJobQueued("ok")).isFalse();
assertThat(future).isCompleted();
}
@Test
@DisplayName("a job past the max wait time still executes and adds a timeout note")
void overdueJobExecutesAndNotes() {
statusRef.set(ResourceStatus.OK);
ReflectionTestUtils.setField(jobQueue, "maxWaitTimeMs", 1L);
CompletableFuture<ResponseEntity<?>> future =
jobQueue.queueJob("overdue", 50, () -> "late-done", 5000);
// Backdate the queuedAt so wait-time exceeds maxWaitTimeMs.
backdateQueuedAt("overdue");
invokeProcessQueue();
awaitDone(future);
assertThat(future).isCompleted();
}
@SuppressWarnings("unchecked")
private void backdateQueuedAt(String jobId) {
var jobMap =
(java.util.Map<String, Object>)
ReflectionTestUtils.getField(jobQueue, "jobMap");
Object job = jobMap.get(jobId);
ReflectionTestUtils.setField(job, "queuedAt", Instant.now().minusSeconds(60));
}
}
@Nested
@DisplayName("executeJob")
class ExecuteJob {
@Test
@DisplayName("a cancelled job is skipped by executeJob without running its work")
@SuppressWarnings("unchecked")
void cancelledJobSkipped() throws Exception {
java.util.concurrent.atomic.AtomicBoolean ran =
new java.util.concurrent.atomic.AtomicBoolean(false);
CompletableFuture<ResponseEntity<?>> future =
jobQueue.queueJob(
"cancelled",
50,
() -> {
ran.set(true);
return "should-not-run";
},
1000);
// Grab the real QueuedJob instance, mark it cancelled, then drive executeJob directly.
var jobMap =
(java.util.Map<String, Object>)
ReflectionTestUtils.getField(jobQueue, "jobMap");
Object job = jobMap.get("cancelled");
ReflectionTestUtils.setField(job, "cancelled", true);
var executeJob = JobQueue.class.getDeclaredMethod("executeJob", job.getClass());
executeJob.setAccessible(true);
executeJob.invoke(jobQueue, job);
// The early return means the work supplier never ran.
assertThat(ran.get()).isFalse();
assertThat(future.isDone()).isFalse();
}
@Test
@DisplayName("a non-ResponseEntity result is wrapped in ResponseEntity.ok")
void nonResponseEntityWrapped() {
statusRef.set(ResourceStatus.OK);
CompletableFuture<ResponseEntity<?>> future =
jobQueue.queueJob("wrap", 50, () -> "plain", 5000);
invokeProcessQueue();
awaitDone(future);
ResponseEntity<?> response = future.join();
assertThat(response.getBody()).isEqualTo("plain");
}
@Test
@DisplayName("a ResponseEntity result is forwarded as-is")
void responseEntityForwarded() {
statusRef.set(ResourceStatus.OK);
ResponseEntity<String> inner = ResponseEntity.ok("inner");
CompletableFuture<ResponseEntity<?>> future =
jobQueue.queueJob("forward", 50, () -> inner, 5000);
invokeProcessQueue();
awaitDone(future);
assertThat(future.join()).isSameAs(inner);
}
@Test
@DisplayName("a failing job completes its future exceptionally")
void failingJobCompletesExceptionally() {
statusRef.set(ResourceStatus.OK);
Supplier<Object> failing =
() -> {
throw new RuntimeException("exec-boom");
};
CompletableFuture<ResponseEntity<?>> future =
jobQueue.queueJob("fail", 50, failing, 5000);
invokeProcessQueue();
awaitDone(future);
assertThat(future).isCompletedExceptionally();
}
}
@Nested
@DisplayName("executeWithTimeout")
class ExecuteWithTimeout {
@Test
@DisplayName("with no timeout it joins and returns the value")
void noTimeoutJoins() {
Object result =
ReflectionTestUtils.invokeMethod(
jobQueue, "executeWithTimeout", (Supplier<Object>) () -> "joined", 0L);
assertThat(result).isEqualTo("joined");
}
@Test
@DisplayName("an execution failure is unwrapped to its cause")
void executionFailureUnwrapped() {
Supplier<Object> failing =
() -> {
throw new IllegalStateException("inner-cause");
};
Throwable thrown =
org.junit.jupiter.api.Assertions.assertThrows(
Throwable.class,
() ->
ReflectionTestUtils.invokeMethod(
jobQueue, "executeWithTimeout", failing, 1000L));
assertThat(messageChain(thrown)).contains("inner-cause");
}
@Test
@DisplayName("a slow job exceeds the timeout and throws TimeoutException")
void slowJobTimesOut() {
Supplier<Object> slow =
() -> {
long start = System.nanoTime();
while (System.nanoTime() - start < 200_000_000L) {
// busy wait beyond 1ms
}
return "late";
};
Throwable thrown =
org.junit.jupiter.api.Assertions.assertThrows(
Throwable.class,
() ->
ReflectionTestUtils.invokeMethod(
jobQueue, "executeWithTimeout", slow, 1L));
assertThat(messageChain(thrown)).contains("timed out");
}
// Spring's ReflectionTestUtils wraps checked exceptions, so inspect the whole cause chain.
private String messageChain(Throwable t) {
StringBuilder sb = new StringBuilder();
for (Throwable c = t; c != null; c = c.getCause()) {
if (c.getMessage() != null) {
sb.append(c.getMessage()).append('|');
}
}
return sb.toString();
}
}
@Nested
@DisplayName("updateQueueCapacity")
class UpdateQueueCapacity {
@Test
@DisplayName("resizes the queue and preserves queued jobs when capacity changes")
void resizesQueue() {
ReflectionTestUtils.setField(jobQueue, "jobQueue", new LinkedBlockingQueue<>(10));
jobQueue.queueJob("keep", 50, () -> "a", 1000);
// Force a new, smaller capacity on the next recalculation.
when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt())).thenReturn(4);
ReflectionTestUtils.invokeMethod(jobQueue, "updateQueueCapacity");
assertThat(jobQueue.getQueueCapacity()).isEqualTo(4);
// The previously queued job survived the drain into the new queue.
assertThat(jobQueue.getCurrentQueueSize()).isEqualTo(1);
}
}
@Nested
@DisplayName("getQueueStats")
class QueueStats {
@Test
@DisplayName("includes the current resource status name")
void includesResourceStatus() {
statusRef.set(ResourceStatus.WARNING);
var stats = jobQueue.getQueueStats();
assertThat(stats.get("resourceStatus")).isEqualTo("WARNING");
assertThat(stats).containsKeys("queuedJobs", "queueCapacity", "rejectedJobs");
}
}
}
@@ -0,0 +1,259 @@
package stirling.software.common.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
import java.lang.management.OperatingSystemMXBean;
import java.time.Instant;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.service.ResourceMonitor.ResourceMetrics;
import stirling.software.common.service.ResourceMonitor.ResourceStatus;
/** Additional coverage for ResourceMonitor branches not exercised by ResourceMonitorTest. */
@ExtendWith(MockitoExtension.class)
class ResourceMonitorMoreTest {
private ResourceMonitor resourceMonitor;
@Mock private OperatingSystemMXBean osMXBean;
@Mock private MemoryMXBean memoryMXBean;
@Mock private MemoryUsage heapUsage;
@Mock private MemoryUsage nonHeapUsage;
private final AtomicReference<ResourceStatus> currentStatus =
new AtomicReference<>(ResourceStatus.OK);
private final AtomicReference<ResourceMetrics> latestMetrics =
new AtomicReference<>(new ResourceMetrics());
@BeforeEach
void setUp() {
resourceMonitor = new ResourceMonitor();
ReflectionTestUtils.setField(resourceMonitor, "memoryCriticalThreshold", 0.9);
ReflectionTestUtils.setField(resourceMonitor, "memoryHighThreshold", 0.75);
ReflectionTestUtils.setField(resourceMonitor, "cpuCriticalThreshold", 0.9);
ReflectionTestUtils.setField(resourceMonitor, "cpuHighThreshold", 0.75);
ReflectionTestUtils.setField(resourceMonitor, "osMXBean", osMXBean);
ReflectionTestUtils.setField(resourceMonitor, "memoryMXBean", memoryMXBean);
ReflectionTestUtils.setField(resourceMonitor, "currentStatus", currentStatus);
ReflectionTestUtils.setField(resourceMonitor, "latestMetrics", latestMetrics);
}
private void stubMemory(long heapUsed, long nonHeapUsed) {
lenient().when(heapUsage.getUsed()).thenReturn(heapUsed);
lenient().when(nonHeapUsage.getUsed()).thenReturn(nonHeapUsed);
lenient().when(memoryMXBean.getHeapMemoryUsage()).thenReturn(heapUsage);
lenient().when(memoryMXBean.getNonHeapMemoryUsage()).thenReturn(nonHeapUsage);
}
@Nested
@DisplayName("updateResourceMetrics status transitions")
class UpdateMetrics {
@Test
@DisplayName("high CPU load drives the status to CRITICAL")
void criticalOnHighCpu() {
// load average / processors = 4 / 2 = 2.0 -> well over critical threshold.
when(osMXBean.getSystemLoadAverage()).thenReturn(4.0);
when(osMXBean.getAvailableProcessors()).thenReturn(2);
stubMemory(1L, 1L);
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.CRITICAL);
assertThat(latestMetrics.get().getCpuUsage()).isEqualTo(2.0);
}
@Test
@DisplayName("moderately high CPU load drives the status to WARNING")
void warningOnModerateCpu() {
// 1.6 / 2 = 0.8 -> above high (0.75) but below critical (0.9).
when(osMXBean.getSystemLoadAverage()).thenReturn(1.6);
when(osMXBean.getAvailableProcessors()).thenReturn(2);
stubMemory(1L, 1L);
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.WARNING);
}
@Test
@DisplayName("low load keeps the status at OK")
void okOnLowLoad() {
when(osMXBean.getSystemLoadAverage()).thenReturn(0.2);
when(osMXBean.getAvailableProcessors()).thenReturn(4);
stubMemory(1L, 1L);
currentStatus.set(ResourceStatus.WARNING); // ensure a transition log path is hit
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.OK);
}
@Test
@DisplayName("a negative load average triggers the alternative CPU fallback")
void negativeLoadUsesFallback() {
// getSystemLoadAverage returns -1 on platforms (e.g. Windows) where it is unsupported.
when(osMXBean.getSystemLoadAverage()).thenReturn(-1.0);
when(osMXBean.getAvailableProcessors()).thenReturn(4);
stubMemory(1L, 1L);
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
// The mock OS bean has no getProcessCpuLoad/getSystemCpuLoad, so fallback yields 0.5.
assertThat(latestMetrics.get().getCpuUsage()).isEqualTo(0.5);
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.OK);
}
@Test
@DisplayName("an exception while sampling is swallowed and status is unchanged")
void samplingExceptionSwallowed() {
when(osMXBean.getSystemLoadAverage()).thenReturn(0.1);
when(osMXBean.getAvailableProcessors()).thenReturn(2);
when(memoryMXBean.getHeapMemoryUsage())
.thenThrow(new RuntimeException("jmx unavailable"));
currentStatus.set(ResourceStatus.OK);
// Must not propagate; the catch in updateResourceMetrics handles it.
ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics");
assertThat(currentStatus.get()).isEqualTo(ResourceStatus.OK);
}
}
@Nested
@DisplayName("getAlternativeCpuLoad")
class AlternativeCpuLoad {
@Test
@DisplayName("uses getProcessCpuLoad via reflection when present")
void usesProcessCpuLoad() {
// A bean exposing getProcessCpuLoad lets the reflective fallback return its value.
OperatingSystemMXBean withCpuLoad = new OsBeanWithProcessCpuLoad(0.42);
ReflectionTestUtils.setField(resourceMonitor, "osMXBean", withCpuLoad);
double load =
(double)
ReflectionTestUtils.invokeMethod(
resourceMonitor, "getAlternativeCpuLoad");
assertThat(load).isEqualTo(0.42);
}
@Test
@DisplayName("defaults to 0.5 when no CPU-load method is available")
void defaultsWhenUnavailable() {
double load =
(double)
ReflectionTestUtils.invokeMethod(
resourceMonitor, "getAlternativeCpuLoad");
assertThat(load).isEqualTo(0.5);
}
}
@Nested
@DisplayName("calculateDynamicQueueCapacity memory pressure")
class MemoryPressure {
@Test
@DisplayName("high memory usage halves the computed capacity")
void highMemoryHalvesCapacity() {
currentStatus.set(ResourceStatus.OK);
// memoryUsage > 0.8 triggers the additional 0.5 multiplier.
latestMetrics.set(new ResourceMetrics(0.1, 0.85, 1, 1, 1, Instant.now()));
int capacity = resourceMonitor.calculateDynamicQueueCapacity(10, 2);
// OK factor 1.0 * 0.5 = 0.5; ceil(10 * 0.5) = 5.
assertThat(capacity).isEqualTo(5);
}
}
@Nested
@DisplayName("ResourceMetrics")
class Metrics {
@Test
@DisplayName("getAge returns a non-negative duration")
void getAgeNonNegative() {
ResourceMetrics m = new ResourceMetrics(0, 0, 0, 0, 0, Instant.now().minusSeconds(1));
assertThat(m.getAge().toMillis()).isGreaterThanOrEqualTo(1000L);
}
}
@Nested
@DisplayName("lifecycle")
class Lifecycle {
@Test
@DisplayName("initialize schedules sampling and shutdown stops the scheduler")
void initializeAndShutdown() {
// Real bean so initialize() schedules against a live virtual-thread scheduler.
ResourceMonitor live = new ResourceMonitor();
ReflectionTestUtils.setField(live, "monitorIntervalMs", 60000L);
live.initialize();
ScheduledExecutorService scheduler =
(ScheduledExecutorService) ReflectionTestUtils.getField(live, "scheduler");
assertThat(scheduler.isShutdown()).isFalse();
live.shutdown();
assertThat(scheduler.isShutdown()).isTrue();
}
}
/** Minimal OS bean stub exposing getProcessCpuLoad so the reflective fallback can find it. */
private static class OsBeanWithProcessCpuLoad implements OperatingSystemMXBean {
private final double cpuLoad;
OsBeanWithProcessCpuLoad(double cpuLoad) {
this.cpuLoad = cpuLoad;
}
// Reflectively located by getAlternativeCpuLoad.
public double getProcessCpuLoad() {
return cpuLoad;
}
@Override
public String getName() {
return "stub";
}
@Override
public String getArch() {
return "stub";
}
@Override
public String getVersion() {
return "stub";
}
@Override
public int getAvailableProcessors() {
return 1;
}
@Override
public double getSystemLoadAverage() {
return -1.0;
}
@Override
public javax.management.ObjectName getObjectName() {
return null;
}
}
}
@@ -0,0 +1,307 @@
package stirling.software.common.service;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Html.UrlSecurity;
import stirling.software.common.service.SsrfProtectionService.SsrfProtectionLevel;
class SsrfProtectionServiceTest {
private ApplicationProperties applicationProperties;
private UrlSecurity config;
private SsrfProtectionService service;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
// Real config object: drill down to the live UrlSecurity instance and mutate it.
config = applicationProperties.getSystem().getHtml().getUrlSecurity();
service = new SsrfProtectionService(applicationProperties);
}
@Nested
@DisplayName("Protection disabled / always-allowed inputs")
class AlwaysAllowed {
@Test
@DisplayName("returns true for any URL when protection disabled")
void disabledAllowsEverything() {
config.setEnabled(false);
assertThat(service.isUrlAllowed("http://169.254.169.254/latest/meta-data")).isTrue();
assertThat(service.isUrlAllowed("http://127.0.0.1")).isTrue();
assertThat(service.isUrlAllowed("not a url")).isTrue();
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t"})
@DisplayName("returns false for null/blank when enabled")
void blankRejected(String url) {
config.setEnabled(true);
assertThat(service.isUrlAllowed(url)).isFalse();
}
@ParameterizedTest
@ValueSource(
strings = {
"data:text/plain;base64,SGVsbG8=",
"DATA:image/png;base64,iVBOR",
"#section",
"#"
})
@DisplayName("data: URLs and fragments are always allowed")
void dataAndFragmentAllowed(String url) {
config.setEnabled(true);
config.setLevel(SsrfProtectionLevel.MAX);
assertThat(service.isUrlAllowed(url)).isTrue();
}
}
@Nested
@DisplayName("OFF level")
class OffLevel {
@Test
@DisplayName("allows external and internal hosts alike")
void offAllowsAll() {
config.setEnabled(true);
config.setLevel(SsrfProtectionLevel.OFF);
assertThat(service.isUrlAllowed("http://10.0.0.1/secret")).isTrue();
assertThat(service.isUrlAllowed("https://example.com")).isTrue();
}
}
@Nested
@DisplayName("MAX level - allowlist only")
class MaxLevel {
@BeforeEach
void max() {
config.setEnabled(true);
config.setLevel(SsrfProtectionLevel.MAX);
}
@Test
@DisplayName("allows only whitelisted hosts (case-insensitive)")
void allowsWhitelistedHost() {
config.setAllowedDomains(List.of("example.com"));
assertThat(service.isUrlAllowed("https://EXAMPLE.com/path")).isTrue();
assertThat(service.isUrlAllowed("https://other.com")).isFalse();
}
@Test
@DisplayName("blocks when allowlist is empty")
void emptyAllowlistBlocks() {
assertThat(service.isUrlAllowed("https://example.com")).isFalse();
}
@Test
@DisplayName("blocks URL with no host")
void noHostBlocked() {
config.setAllowedDomains(List.of("example.com"));
assertThat(service.isUrlAllowed("file:///etc/passwd")).isFalse();
}
@Test
@DisplayName("blocks malformed URL (parse exception path)")
void malformedBlocked() {
config.setAllowedDomains(List.of("example.com"));
assertThat(service.isUrlAllowed("http://exa mple.com")).isFalse();
}
}
@Nested
@DisplayName("MEDIUM level - host parsing and lists")
class MediumHostAndLists {
@BeforeEach
void medium() {
config.setEnabled(true);
config.setLevel(SsrfProtectionLevel.MEDIUM);
}
@Test
@DisplayName("allows a normal public literal IP")
void allowsPublicIp() {
assertThat(service.isUrlAllowed("http://93.184.216.34/page")).isTrue();
}
@Test
@DisplayName("blocks URL with no host")
void noHostBlocked() {
assertThat(service.isUrlAllowed("mailto:test@example.com")).isFalse();
}
@Test
@DisplayName("blocks malformed URL (parse exception path)")
void malformedBlocked() {
assertThat(service.isUrlAllowed("ht!tp://%%%")).isFalse();
}
@Test
@DisplayName("blocks explicitly blocked domain (case-insensitive)")
void blockedDomain() {
config.setBlockedDomains(List.of("evil.com"));
assertThat(service.isUrlAllowed("http://EVIL.com")).isFalse();
}
@Test
@DisplayName("blocks internal TLD suffixes")
void internalTld() {
// default internalTlds include .local, .internal, .corp, .home
assertThat(service.isUrlAllowed("http://server.local")).isFalse();
assertThat(service.isUrlAllowed("http://host.internal")).isFalse();
}
@Test
@DisplayName("allowlist present: host not in list is blocked before any DNS lookup")
void allowlistRejectsUnlisted() {
// notexample.com is rejected by the allowlist check, which runs before DNS resolution,
// so this stays deterministic offline.
config.setAllowedDomains(List.of("example.com"));
assertThat(service.isUrlAllowed("http://notexample.com")).isFalse();
}
@Test
@DisplayName("allowlist present: exact host and subdomain pass the allowlist gate")
void allowlistAcceptsExactAndSubdomain() {
// Allow a literal IP so the subsequent DNS resolution is the identity and network
// checks are disabled, keeping the allow path deterministic without external DNS.
config.setBlockPrivateNetworks(false);
config.setBlockLocalhost(false);
config.setBlockLinkLocal(false);
config.setBlockCloudMetadata(false);
config.setAllowedDomains(List.of("93.184.216.34"));
assertThat(service.isUrlAllowed("http://93.184.216.34")).isTrue();
}
}
@Nested
@DisplayName("MEDIUM level - network based blocking via literal IPs")
class MediumNetworkBlocking {
@BeforeEach
void medium() {
config.setEnabled(true);
config.setLevel(SsrfProtectionLevel.MEDIUM);
}
@Test
@DisplayName("blocks loopback when blockLocalhost enabled")
void blocksLoopback() {
assertThat(service.isUrlAllowed("http://127.0.0.1/admin")).isFalse();
}
@Test
@DisplayName("allows loopback when blockLocalhost disabled and private/link checks off")
void allowsLoopbackWhenAllChecksOff() {
config.setBlockLocalhost(false);
config.setBlockPrivateNetworks(false);
config.setBlockLinkLocal(false);
config.setBlockCloudMetadata(false);
assertThat(service.isUrlAllowed("http://127.0.0.1/ok")).isTrue();
}
@ParameterizedTest
@ValueSource(
strings = {
"http://10.1.2.3",
"http://192.168.0.5",
"http://172.16.0.9",
"http://172.31.255.1",
"http://100.64.0.1"
})
@DisplayName("blocks RFC1918 / CGNAT private ranges")
void blocksPrivateRanges(String url) {
assertThat(service.isUrlAllowed(url)).isFalse();
}
@Test
@DisplayName("172.x and 100.x outside private sub-range are not private")
void boundaryRangesNotPrivate() {
// 172.15/172.32 outside 16-31; 100.63/100.128 outside 64-127.
assertThat(service.isUrlAllowed("http://172.15.0.1")).isTrue();
assertThat(service.isUrlAllowed("http://172.32.0.1")).isTrue();
assertThat(service.isUrlAllowed("http://100.63.0.1")).isTrue();
}
@Test
@DisplayName("allows private range when blockPrivateNetworks disabled")
void allowsPrivateWhenDisabled() {
config.setBlockPrivateNetworks(false);
config.setBlockLocalhost(false);
assertThat(service.isUrlAllowed("http://10.1.2.3")).isTrue();
}
@Test
@DisplayName("blocks link-local 169.254.x via private-network check")
void blocksLinkLocal() {
assertThat(service.isUrlAllowed("http://169.254.1.1")).isFalse();
}
@Test
@DisplayName("blocks AWS cloud-metadata IP 169.254.169.254")
void blocksCloudMetadata() {
assertThat(service.isUrlAllowed("http://169.254.169.254/latest/meta-data/")).isFalse();
}
@Test
@DisplayName("blocks unspecified address 0.0.0.0")
void blocksUnspecified() {
assertThat(service.isUrlAllowed("http://0.0.0.0")).isFalse();
}
@Test
@DisplayName("blocks unresolvable host (UnknownHostException path)")
void blocksUnresolvableHost() {
assertThat(service.isUrlAllowed("http://nonexistent-host-stirling-test.invalid/page"))
.isFalse();
}
}
@Nested
@DisplayName("MEDIUM level - IPv6 literal handling")
class MediumIpv6 {
@BeforeEach
void medium() {
config.setEnabled(true);
config.setLevel(SsrfProtectionLevel.MEDIUM);
}
@Test
@DisplayName("blocks IPv6 loopback ::1")
void blocksIpv6Loopback() {
assertThat(service.isUrlAllowed("http://[::1]/path")).isFalse();
}
@Test
@DisplayName("blocks IPv6 unique-local fc00::/7")
void blocksIpv6UniqueLocal() {
assertThat(service.isUrlAllowed("http://[fc00::1]")).isFalse();
}
@Test
@DisplayName("blocks IPv6 link-local fe80::/10")
void blocksIpv6LinkLocal() {
assertThat(service.isUrlAllowed("http://[fe80::1]")).isFalse();
}
@Test
@DisplayName("blocks IPv4-mapped IPv6 of a private address")
void blocksIpv4MappedPrivate() {
assertThat(service.isUrlAllowed("http://[::ffff:10.0.0.1]")).isFalse();
}
}
}
@@ -0,0 +1,365 @@
package stirling.software.common.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats;
import stirling.software.common.model.job.ResultFile;
/** Additional coverage for TaskManager branches not exercised by TaskManagerTest. */
class TaskManagerMoreTest {
@Mock private FileStorage fileStorage;
@Mock private JobStore jobStore;
@Mock private ClusterBackplane clusterBackplane;
@InjectMocks private TaskManager taskManager;
private AutoCloseable closeable;
@BeforeEach
void setUp() {
closeable = MockitoAnnotations.openMocks(this);
lenient().when(clusterBackplane.localNodeId()).thenReturn("test-node");
lenient().when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(true);
ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30);
}
@AfterEach
void tearDown() throws Exception {
closeable.close();
}
private static byte[] buildZip(String... entryNames) throws Exception {
var baos = new java.io.ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
for (String name : entryNames) {
zos.putNextEntry(new ZipEntry(name));
zos.write(("content-of-" + name).getBytes());
zos.closeEntry();
}
}
return baos.toByteArray();
}
@Nested
@DisplayName("setFileResult ZIP handling")
class ZipHandling {
@Test
@DisplayName("extracts a ZIP into individual file results and deletes the original")
void extractsZipIntoIndividualFiles() throws Exception {
String jobId = "zip-job";
taskManager.createTask(jobId);
byte[] zipBytes = buildZip("a.pdf", "b.txt");
when(fileStorage.retrieveInputStream("zip-file-id"))
.thenReturn(new ByteArrayInputStream(zipBytes));
// Each extracted entry is stored, returning a distinct StoredFile.
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
.thenReturn(new FileStorage.StoredFile("extracted-a", 11L))
.thenReturn(new FileStorage.StoredFile("extracted-b", 22L));
when(fileStorage.deleteFile("zip-file-id")).thenReturn(true);
taskManager.setFileResult(jobId, "zip-file-id", "bundle.zip", "application/zip");
JobResult result = taskManager.getJobResult(jobId);
assertThat(result.isComplete()).isTrue();
assertThat(result.hasMultipleFiles()).isTrue();
assertThat(result.getAllResultFiles()).hasSize(2);
// Content type is derived from the entry extension, not the ZIP content type.
assertThat(result.getAllResultFiles().get(0).getContentType())
.isEqualTo(MediaType.APPLICATION_PDF_VALUE);
assertThat(result.getAllResultFiles().get(1).getContentType())
.isEqualTo(MediaType.TEXT_PLAIN_VALUE);
verify(fileStorage).deleteFile("zip-file-id");
}
@Test
@DisplayName("empty ZIP falls back to a single-file result")
void emptyZipFallsBackToSingleFile() throws Exception {
String jobId = "empty-zip-job";
taskManager.createTask(jobId);
byte[] emptyZip = buildZip();
when(fileStorage.retrieveInputStream("empty-zip-id"))
.thenReturn(new ByteArrayInputStream(emptyZip));
when(fileStorage.getFileSize("empty-zip-id")).thenReturn(7L);
taskManager.setFileResult(jobId, "empty-zip-id", "empty.zip", "application/zip");
JobResult result = taskManager.getJobResult(jobId);
assertThat(result.hasMultipleFiles()).isFalse();
assertThat(result.getAllResultFiles()).hasSize(1);
assertThat(result.getAllResultFiles().get(0).getFileId()).isEqualTo("empty-zip-id");
}
@Test
@DisplayName("ZIP extraction failure falls back to a single-file result")
void zipExtractionFailureFallsBackToSingleFile() throws Exception {
String jobId = "bad-zip-job";
taskManager.createTask(jobId);
// retrieveInputStream throws so extractZipToIndividualFiles fails and we fall back.
when(fileStorage.retrieveInputStream("bad-zip-id"))
.thenThrow(new java.io.IOException("boom"));
when(fileStorage.getFileSize("bad-zip-id")).thenReturn(99L);
taskManager.setFileResult(
jobId, "bad-zip-id", "broken.zip", "application/x-zip-compressed");
JobResult result = taskManager.getJobResult(jobId);
assertThat(result.hasFiles()).isTrue();
assertThat(result.getAllResultFiles().get(0).getFileId()).isEqualTo("bad-zip-id");
}
}
@Nested
@DisplayName("setFileResult size fallback")
class SizeFallback {
@Test
@DisplayName("uses size 0 when getFileSize throws for a non-zip file")
void usesZeroSizeWhenGetFileSizeThrows() throws Exception {
String jobId = "size-fail-job";
taskManager.createTask(jobId);
when(fileStorage.getFileSize("file-x")).thenThrow(new java.io.IOException("no stat"));
taskManager.setFileResult(jobId, "file-x", "doc.pdf", MediaType.APPLICATION_PDF_VALUE);
JobResult result = taskManager.getJobResult(jobId);
assertThat(result.getAllResultFiles().get(0).getFileSize()).isZero();
}
}
@Nested
@DisplayName("setMultipleFileResults")
class MultipleFileResults {
@Test
@DisplayName("stores the provided list directly")
void storesProvidedList() {
String jobId = "multi-job";
taskManager.createTask(jobId);
List<ResultFile> files =
List.of(
ResultFile.builder().fileId("f1").fileName("1.pdf").build(),
ResultFile.builder().fileId("f2").fileName("2.pdf").build());
taskManager.setMultipleFileResults(jobId, files);
JobResult result = taskManager.getJobResult(jobId);
assertThat(result.hasMultipleFiles()).isTrue();
assertThat(result.getAllResultFiles()).hasSize(2);
}
}
@Nested
@DisplayName("getJobStats edge cases")
class StatsEdgeCases {
@Test
@DisplayName("empty manager reports zero average processing time")
void emptyManagerZeroAverage() {
JobStats stats = taskManager.getJobStats();
assertThat(stats.getTotalJobs()).isZero();
assertThat(stats.getAverageProcessingTimeMs()).isZero();
assertThat(stats.getOldestActiveJobTime()).isNull();
}
@Test
@DisplayName("accumulates processing time across multiple completed jobs")
void accumulatesProcessingTime() {
taskManager.createTask("c1");
taskManager.setResult("c1", "r1");
taskManager.createTask("c2");
taskManager.setResult("c2", "r2");
JobStats stats = taskManager.getJobStats();
assertThat(stats.getCompletedJobs()).isEqualTo(2);
assertThat(stats.getSuccessfulJobs()).isEqualTo(2);
assertThat(stats.getAverageProcessingTimeMs()).isGreaterThanOrEqualTo(0);
}
}
@Nested
@DisplayName("findResultFileByFileId")
class FindResultFile {
@Test
@DisplayName("returns matching ResultFile metadata")
void returnsMatch() throws Exception {
taskManager.createTask("rf-job");
when(fileStorage.getFileSize("target")).thenReturn(5L);
taskManager.setFileResult("rf-job", "target", "t.pdf", MediaType.APPLICATION_PDF_VALUE);
ResultFile found = taskManager.findResultFileByFileId("target");
assertThat(found).isNotNull();
assertThat(found.getFileId()).isEqualTo("target");
}
@Test
@DisplayName("returns null when no job owns the file id")
void returnsNullWhenAbsent() {
assertThat(taskManager.findResultFileByFileId("nope")).isNull();
}
}
@Nested
@DisplayName("findJobKeyByFileId")
class FindJobKey {
@Test
@DisplayName("returns the local job key when a job owns the file id")
void returnsLocalKey() throws Exception {
taskManager.createTask("owner-job");
when(fileStorage.getFileSize("owned")).thenReturn(3L);
taskManager.setFileResult(
"owner-job", "owned", "o.pdf", MediaType.APPLICATION_PDF_VALUE);
assertThat(taskManager.findJobKeyByFileId("owned")).isEqualTo("owner-job");
// Local hit must not consult the JobStore.
verify(jobStore, never()).findJobIdByFileId(anyString());
}
@Test
@DisplayName("returns null when JobStore also has no match")
void returnsNullWhenJobStoreEmpty() {
when(jobStore.findJobIdByFileId("ghost")).thenReturn(Optional.empty());
assertThat(taskManager.findJobKeyByFileId("ghost")).isNull();
}
@Test
@DisplayName("propagates JobStore lookup failures instead of returning null")
void propagatesJobStoreFailure() {
when(jobStore.findJobIdByFileId("blip"))
.thenThrow(new RuntimeException("backplane down"));
assertThatThrownBy(() -> taskManager.findJobKeyByFileId("blip"))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("backplane down");
}
}
@Nested
@DisplayName("cleanupOldJobs resilience")
class CleanupResilience {
@Test
@DisplayName("continues when a file deletion throws during cleanup")
void continuesWhenDeleteThrows() throws Exception {
String jobId = "old-file-job";
taskManager.createTask(jobId);
JobResult job = taskManager.getJobResult(jobId);
ResultFile rf =
ResultFile.builder()
.fileId("doomed")
.fileName("d.pdf")
.contentType(MediaType.APPLICATION_PDF_VALUE)
.fileSize(1L)
.build();
ReflectionTestUtils.setField(job, "resultFiles", List.of(rf));
ReflectionTestUtils.setField(job, "complete", true);
ReflectionTestUtils.setField(job, "completedAt", LocalDateTime.now().minusHours(2));
when(fileStorage.deleteFile("doomed")).thenThrow(new RuntimeException("locked"));
// Must not propagate; the job is still removed afterwards.
taskManager.cleanupOldJobs();
@SuppressWarnings("unchecked")
Map<String, JobResult> map =
(Map<String, JobResult>)
ReflectionTestUtils.getField(taskManager, "jobResults");
assertThat(map).doesNotContainKey(jobId);
}
}
@Nested
@DisplayName("write-through failures")
class WriteThroughFailures {
@Test
@DisplayName("a JobStore put failure does not break createTask")
void putFailureSwallowed() {
org.mockito.Mockito.doThrow(new RuntimeException("store offline"))
.when(jobStore)
.put(any(), any());
// createTask -> writeThrough; the RuntimeException is caught and logged.
taskManager.createTask("wt-job");
assertThat(taskManager.getJobResult("wt-job")).isNotNull();
}
}
@Nested
@DisplayName("toEntry mapping")
class ToEntryMapping {
@Test
@DisplayName("a failed job maps to FAILED state in the JobStore entry")
void failedJobMapsToFailedState() {
taskManager.createTask("fail-job");
taskManager.setError("fail-job", "kaboom");
var captor =
org.mockito.ArgumentCaptor.forClass(
stirling.software.common.cluster.JobStoreEntry.class);
verify(jobStore, org.mockito.Mockito.atLeastOnce()).put(captor.capture(), any());
assertThat(captor.getValue().jobId()).isEqualTo("fail-job");
assertThat(captor.getAllValues())
.anySatisfy(
e ->
assertThat(e.state())
.isEqualTo(
stirling.software.common.cluster.JobStoreEntry
.JobState.FAILED));
}
}
@Nested
@DisplayName("addNote write-through")
class AddNoteWriteThrough {
@Test
@DisplayName("note is reflected in JobStore entry metadata")
void noteWritesMetadata() {
taskManager.createTask("note-job");
assertThat(taskManager.addNote("note-job", "hello")).isTrue();
var captor =
org.mockito.ArgumentCaptor.forClass(
stirling.software.common.cluster.JobStoreEntry.class);
verify(jobStore, org.mockito.Mockito.atLeastOnce()).put(captor.capture(), any());
assertThat(captor.getAllValues())
.anySatisfy(e -> assertThat(e.resultMeta()).containsKey("notesCount"));
}
}
}
@@ -0,0 +1,379 @@
package stirling.software.common.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Consumer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
/** Additional coverage for TempFileCleanupService branches not exercised by the base test. */
class TempFileCleanupServiceMoreTest {
@TempDir Path tempDir;
@Mock private TempFileRegistry registry;
@Mock private TempFileManager tempFileManager;
@Mock private ApplicationProperties applicationProperties;
@Mock private ApplicationProperties.System system;
@Mock private ApplicationProperties.TempFileManagement tempFileManagement;
@InjectMocks private TempFileCleanupService cleanupService;
private Path systemTempDir;
private Path customTempDir;
private Path libreOfficeTempDir;
private AutoCloseable closeable;
@BeforeEach
void setUp() throws IOException {
closeable = MockitoAnnotations.openMocks(this);
systemTempDir = tempDir.resolve("systemTemp");
customTempDir = tempDir.resolve("customTemp");
libreOfficeTempDir = tempDir.resolve("libreOfficeTemp");
Files.createDirectories(systemTempDir);
Files.createDirectories(customTempDir);
Files.createDirectories(libreOfficeTempDir);
lenient().when(applicationProperties.getSystem()).thenReturn(system);
lenient().when(system.getTempFileManagement()).thenReturn(tempFileManagement);
lenient().when(tempFileManagement.getBaseTmpDir()).thenReturn(customTempDir.toString());
lenient()
.when(tempFileManagement.getLibreofficeDir())
.thenReturn(libreOfficeTempDir.toString());
lenient().when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString());
lenient().when(tempFileManagement.isStartupCleanup()).thenReturn(false);
lenient().when(tempFileManagement.isCleanupSystemTemp()).thenReturn(false);
ReflectionTestUtils.setField(cleanupService, "machineType", "Standard");
lenient().when(tempFileManager.getMaxAgeMillis()).thenReturn(3600000L);
}
@AfterEach
void tearDown() throws Exception {
closeable.close();
}
private static void backdate(Path file, long millisAgo) throws IOException {
Files.setLastModifiedTime(
file, FileTime.fromMillis(System.currentTimeMillis() - millisAgo));
}
@Nested
@DisplayName("isContainerMode")
class ContainerMode {
@Test
@DisplayName("Docker and Kubernetes are container modes; others are not")
void detectsContainerMachineTypes() {
ReflectionTestUtils.setField(cleanupService, "machineType", "Docker");
assertThat(
(Boolean)
ReflectionTestUtils.invokeMethod(
cleanupService, "isContainerMode"))
.isTrue();
ReflectionTestUtils.setField(cleanupService, "machineType", "Kubernetes");
assertThat(
(Boolean)
ReflectionTestUtils.invokeMethod(
cleanupService, "isContainerMode"))
.isTrue();
ReflectionTestUtils.setField(cleanupService, "machineType", "Standard");
assertThat(
(Boolean)
ReflectionTestUtils.invokeMethod(
cleanupService, "isContainerMode"))
.isFalse();
}
}
@Nested
@DisplayName("getSystemTempPath")
class SystemTempPath {
@Test
@DisplayName("uses the configured system temp dir when set")
void usesConfiguredDir() {
when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString());
Path path =
(Path) ReflectionTestUtils.invokeMethod(cleanupService, "getSystemTempPath");
assertThat(path).isEqualTo(systemTempDir);
}
@Test
@DisplayName("falls back to java.io.tmpdir when unset")
void fallsBackToJavaTmpDir() {
when(tempFileManagement.getSystemTempDir()).thenReturn("");
Path path =
(Path) ReflectionTestUtils.invokeMethod(cleanupService, "getSystemTempPath");
assertThat(path).isEqualTo(Path.of(System.getProperty("java.io.tmpdir")));
}
}
@Nested
@DisplayName("init")
class Init {
@Test
@DisplayName("creates configured temp directories that do not yet exist")
void createsMissingDirectories() {
Path newBase = tempDir.resolve("newBase");
Path newLo = tempDir.resolve("newLo");
when(tempFileManagement.getBaseTmpDir()).thenReturn(newBase.toString());
when(tempFileManagement.getLibreofficeDir()).thenReturn(newLo.toString());
when(tempFileManagement.isStartupCleanup()).thenReturn(false);
cleanupService.init();
assertThat(Files.exists(newBase)).isTrue();
assertThat(Files.exists(newLo)).isTrue();
}
@Test
@DisplayName("runs startup cleanup when enabled")
void runsStartupCleanupWhenEnabled() throws IOException {
when(tempFileManagement.isStartupCleanup()).thenReturn(true);
when(registry.contains(any(File.class))).thenReturn(false);
// An old stirling temp file in the custom dir should be removed by startup cleanup.
Path stale = Files.createFile(customTempDir.resolve("stirling-pdf-stale.tmp"));
backdate(stale, 48L * 60 * 60 * 1000); // 48h old, beyond non-container 24h cutoff
cleanupService.init();
assertThat(Files.exists(stale)).isFalse();
}
}
@Nested
@DisplayName("scheduledCleanup")
class ScheduledCleanup {
@Test
@DisplayName("deletes registered temp directories and reports counts")
void deletesRegisteredDirectories() throws IOException {
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(2);
Path regDir = Files.createDirectories(tempDir.resolve("registeredDir"));
Files.createFile(regDir.resolve("inside.txt"));
Set<Path> dirs = new HashSet<>();
dirs.add(regDir);
when(registry.getTempDirectories()).thenReturn(dirs);
lenient().when(registry.contains(any(File.class))).thenReturn(false);
withIsolatedUserHome(cleanupService::scheduledCleanup);
// The registered directory was removed by GeneralUtils.deleteDirectory.
assertThat(Files.exists(regDir)).isFalse();
verify(tempFileManager).cleanupOldTempFiles(anyLong());
}
@Test
@DisplayName("skips a registered directory that no longer exists")
void skipsMissingRegisteredDirectory() {
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(0);
Set<Path> dirs = new HashSet<>();
dirs.add(tempDir.resolve("ghostDir"));
when(registry.getTempDirectories()).thenReturn(dirs);
lenient().when(registry.contains(any(File.class))).thenReturn(false);
// No exception even though the directory does not exist.
withIsolatedUserHome(cleanupService::scheduledCleanup);
verify(registry).getTempDirectories();
}
}
@Nested
@DisplayName("cleanupUnregisteredFiles system-temp inclusion")
class CleanupUnregistered {
@Test
@DisplayName("includes the system temp dir when cleanupSystemTemp is enabled")
void includesSystemTempDir() throws Exception {
when(tempFileManagement.isCleanupSystemTemp()).thenReturn(true);
when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString());
when(registry.contains(any(File.class))).thenReturn(false);
// Old stirling file in the system temp dir should be deleted in container mode.
Path stale = Files.createFile(systemTempDir.resolve("stirling-pdf-sys.tmp"));
backdate(stale, 2L * 60 * 60 * 1000); // 2h old
int deleted =
(int)
ReflectionTestUtils.invokeMethod(
cleanupService,
"cleanupUnregisteredFiles",
true,
true,
3600000L);
assertThat(deleted).isGreaterThanOrEqualTo(1);
assertThat(Files.exists(stale)).isFalse();
}
}
@Nested
@DisplayName("registered-file skip and recursion depth")
class RegistryAndDepth {
@Test
@DisplayName("a registered file is never deleted")
void registeredFilePreserved() throws Exception {
Path registered = Files.createFile(systemTempDir.resolve("output_registered.pdf"));
backdate(registered, 2L * 60 * 60 * 1000);
// The registry reports the file as registered, so cleanup must skip it.
when(registry.contains(any(File.class))).thenReturn(true);
invokeCleanupDirectoryStreaming(systemTempDir, 0, false, 3600000L);
assertThat(Files.exists(registered)).isTrue();
}
@Test
@DisplayName("recursion stops once the maximum depth is exceeded")
void recursionDepthGuard() throws Exception {
// Starting beyond MAX_RECURSION_DEPTH (5) returns immediately without listing.
Path deepFile = Files.createFile(systemTempDir.resolve("output_deep.pdf"));
backdate(deepFile, 2L * 60 * 60 * 1000);
lenient().when(registry.contains(any(File.class))).thenReturn(false);
invokeCleanupDirectoryStreaming(systemTempDir, 6, false, 3600000L);
// Depth guard hit: the file was not visited or deleted.
assertThat(Files.exists(deepFile)).isTrue();
}
}
@Nested
@DisplayName("cleanupLibreOfficeTempFiles")
class LibreOfficeCleanup {
@Test
@DisplayName("clears contents of registered libreoffice directories but keeps the dir")
void clearsLibreOfficeContents() throws IOException {
Path loDir = Files.createDirectories(tempDir.resolve("libreoffice-conv"));
Path inside = Files.createFile(loDir.resolve("output_lo.pdf"));
Set<Path> dirs = new HashSet<>();
dirs.add(loDir);
when(registry.getTempDirectories()).thenReturn(dirs);
when(registry.contains(any(File.class))).thenReturn(false);
cleanupService.cleanupLibreOfficeTempFiles();
// The file is removed (age ignored), directory itself remains.
assertThat(Files.exists(inside)).isFalse();
assertThat(Files.exists(loDir)).isTrue();
}
@Test
@DisplayName("ignores registered directories that are not libreoffice dirs")
void ignoresNonLibreOfficeDirs() throws IOException {
Path other = Files.createDirectories(tempDir.resolve("other-dir"));
Path keep = Files.createFile(other.resolve("output_keep.pdf"));
Set<Path> dirs = new HashSet<>();
dirs.add(other);
when(registry.getTempDirectories()).thenReturn(dirs);
cleanupService.cleanupLibreOfficeTempFiles();
// Not a libreoffice dir, so its contents are untouched.
assertThat(Files.exists(keep)).isTrue();
}
}
@Nested
@DisplayName("cleanupPDFBoxCache")
class PdfBoxCache {
@Test
@DisplayName("deletes an existing .pdfbox.cache file in the user home")
void deletesCacheFile() throws IOException {
Path fakeHome = Files.createDirectories(tempDir.resolve("home"));
Path cache = Files.createFile(fakeHome.resolve(".pdfbox.cache"));
String oldHome = System.getProperty("user.home");
try {
System.setProperty("user.home", fakeHome.toString());
ReflectionTestUtils.invokeMethod(cleanupService, "cleanupPDFBoxCache");
assertThat(Files.exists(cache)).isFalse();
} finally {
System.setProperty("user.home", oldHome);
}
}
@Test
@DisplayName("is a no-op when no cache file exists")
void noOpWhenNoCache() throws IOException {
Path fakeHome = Files.createDirectories(tempDir.resolve("home2"));
String oldHome = System.getProperty("user.home");
try {
System.setProperty("user.home", fakeHome.toString());
// No exception when the cache file is absent.
ReflectionTestUtils.invokeMethod(cleanupService, "cleanupPDFBoxCache");
assertThat(Files.exists(fakeHome.resolve(".pdfbox.cache"))).isFalse();
} finally {
System.setProperty("user.home", oldHome);
}
}
}
// Point user.home at a throwaway dir so the real ~/.pdfbox.cache is never touched.
private void withIsolatedUserHome(Runnable action) {
String oldHome = System.getProperty("user.home");
try {
Path fakeHome = Files.createDirectories(tempDir.resolve("isolated-home"));
System.setProperty("user.home", fakeHome.toString());
action.run();
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
System.setProperty("user.home", oldHome);
}
}
private void invokeCleanupDirectoryStreaming(
Path directory, int depth, boolean containerMode, long maxAgeMillis) {
try {
Consumer<Path> noop = p -> {};
var method =
TempFileCleanupService.class.getDeclaredMethod(
"cleanupDirectoryStreaming",
Path.class,
boolean.class,
int.class,
long.class,
boolean.class,
Consumer.class);
method.setAccessible(true);
method.invoke(
cleanupService, directory, containerMode, depth, maxAgeMillis, false, noop);
} catch (Exception e) {
throw new RuntimeException("Error invoking cleanupDirectoryStreaming", e);
}
}
}
@@ -0,0 +1,120 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
/**
* Gap-filling tests for {@link CbrUtils#convertCbrToPdf}. junrar cannot parse synthetic RAR data,
* so these exercise the archive-open failure branches (corrupt header / invalid format) by feeding
* non-RAR bytes through a real {@link CustomPDFDocumentFactory} and {@link TempFileManager}. No
* external tool is launched.
*/
class CbrUtilsMoreTest {
private TempFileManager tempFileManager;
private CustomPDFDocumentFactory factory;
@TempDir Path tempDir;
@BeforeEach
void setUp() {
ApplicationProperties props = new ApplicationProperties();
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
props.getSystem().getTempFileManagement().setPrefix("test-cbr-");
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
factory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
}
private static MultipartFile cbr(String filename, byte[] bytes) {
return new MockMultipartFile("file", filename, "application/x-cbr", bytes);
}
@Nested
@DisplayName("convertCbrToPdf - invalid archives")
class InvalidArchiveTests {
@Test
@DisplayName("non-RAR bytes in a .cbr file are rejected as an invalid archive")
void nonRarContentCbr() {
byte[] junk = "this is not a rar archive at all".getBytes(StandardCharsets.UTF_8);
assertThatThrownBy(
() ->
CbrUtils.convertCbrToPdf(
cbr("comic.cbr", junk), factory, tempFileManager))
.isInstanceOf(Exception.class);
}
@Test
@DisplayName("non-RAR bytes in a .rar file are rejected as an invalid archive")
void nonRarContentRar() {
byte[] junk = new byte[] {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07};
assertThatThrownBy(
() ->
CbrUtils.convertCbrToPdf(
cbr("archive.rar", junk), factory, tempFileManager))
.isInstanceOf(Exception.class);
}
@Test
@DisplayName("bytes carrying the RAR signature but no valid body are rejected")
void rarSignatureOnly() {
// "Rar!\x1A\x07\x00" is the classic RAR4 signature; body is missing/garbage.
byte[] data = {0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55};
assertThatThrownBy(
() ->
CbrUtils.convertCbrToPdf(
cbr("comic.cbr", data), factory, tempFileManager))
.isInstanceOf(Exception.class);
}
}
@Nested
@DisplayName("convertCbrToPdf - validation overload")
class ValidationTests {
@Test
@DisplayName("the 3-arg overload delegates and still validates the extension")
void threeArgOverloadValidatesExtension() {
MultipartFile wrong = cbr("document.pdf", "x".getBytes(StandardCharsets.UTF_8));
assertThatThrownBy(() -> CbrUtils.convertCbrToPdf(wrong, factory, tempFileManager))
.isInstanceOf(Exception.class);
}
@Test
@DisplayName("an empty .cbr file is rejected before archive parsing")
void emptyFile() {
MultipartFile empty = cbr("comic.cbr", new byte[0]);
assertThatThrownBy(() -> CbrUtils.convertCbrToPdf(empty, factory, tempFileManager))
.isInstanceOf(Exception.class);
}
}
@Nested
@DisplayName("isCbrFile additional branches")
class IsCbrFileTests {
@Test
@DisplayName("a .zip file is not a CBR")
void zipIsNotCbr() {
MultipartFile file = mock(MultipartFile.class);
org.mockito.Mockito.when(file.getOriginalFilename()).thenReturn("bundle.zip");
assertThat(CbrUtils.isCbrFile(file)).isFalse();
}
}
}
@@ -0,0 +1,204 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import javax.imageio.ImageIO;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
/**
* Gap-filling tests for {@link CbzUtils#convertCbzToPdf} that build real in-memory CBZ (ZIP)
* archives containing real PNG images and convert them with a real {@link
* CustomPDFDocumentFactory}. No external process is launched (optimizeForEbook is left off so
* Ghostscript is never invoked).
*/
class CbzUtilsMoreTest {
private TempFileManager tempFileManager;
private CustomPDFDocumentFactory factory;
@TempDir Path tempDir;
@BeforeEach
void setUp() {
ApplicationProperties props = new ApplicationProperties();
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
props.getSystem().getTempFileManagement().setPrefix("test-cbz-");
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
factory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
}
private static byte[] pngBytes(Color color) throws IOException {
BufferedImage img = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
g.setColor(color);
g.fillRect(0, 0, 20, 20);
g.dispose();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(img, "PNG", baos);
return baos.toByteArray();
}
/** Build a CBZ (ZIP) from name->bytes entries. */
private static byte[] buildCbz(String[] names, byte[][] contents) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
for (int i = 0; i < names.length; i++) {
zos.putNextEntry(new ZipEntry(names[i]));
if (contents[i] != null) {
zos.write(contents[i]);
}
zos.closeEntry();
}
}
return baos.toByteArray();
}
private static MultipartFile cbz(byte[] bytes) {
return new MockMultipartFile("file", "comic.cbz", "application/x-cbz", bytes);
}
@Nested
@DisplayName("convertCbzToPdf - happy path")
class HappyPathTests {
@Test
@DisplayName("a CBZ with two images converts to a two-page PDF, sorted by natural order")
void twoImagesToPdf() throws Exception {
byte[] archive =
buildCbz(
new String[] {"page2.png", "page10.png", "page1.png"},
new byte[][] {
pngBytes(Color.RED), pngBytes(Color.GREEN), pngBytes(Color.BLUE)
});
try (TempFile resultPdf =
CbzUtils.convertCbzToPdf(cbz(archive), factory, tempFileManager, false)) {
assertThat(resultPdf.exists()).isTrue();
try (PDDocument doc = Loader.loadPDF(resultPdf.getFile())) {
assertThat(doc.getNumberOfPages()).isEqualTo(3);
}
}
}
@Test
@DisplayName("non-image entries are ignored, only images become pages")
void mixedEntries() throws Exception {
byte[] archive =
buildCbz(
new String[] {"readme.txt", "cover.png"},
new byte[][] {
"notes".getBytes(StandardCharsets.UTF_8), pngBytes(Color.CYAN)
});
try (TempFile resultPdf =
CbzUtils.convertCbzToPdf(cbz(archive), factory, tempFileManager, false)) {
try (PDDocument doc = Loader.loadPDF(resultPdf.getFile())) {
assertThat(doc.getNumberOfPages()).isEqualTo(1);
}
}
}
}
@Nested
@DisplayName("convertCbzToPdf - invalid archives")
class InvalidArchiveTests {
@Test
@DisplayName("an empty ZIP (no entries) is rejected")
void emptyArchive() throws Exception {
byte[] archive = buildCbz(new String[] {}, new byte[][] {});
assertThatThrownBy(
() ->
CbzUtils.convertCbzToPdf(
cbz(archive), factory, tempFileManager, false))
.isInstanceOf(Exception.class);
}
@Test
@DisplayName("a ZIP with no image entries is rejected as 'no images'")
void noImageEntries() throws Exception {
byte[] archive =
buildCbz(
new String[] {"a.txt", "b.json"},
new byte[][] {
"x".getBytes(StandardCharsets.UTF_8),
"{}".getBytes(StandardCharsets.UTF_8)
});
assertThatThrownBy(
() ->
CbzUtils.convertCbzToPdf(
cbz(archive), factory, tempFileManager, false))
.isInstanceOf(Exception.class);
}
@Test
@DisplayName("non-ZIP bytes are rejected as an invalid CBZ format")
void corruptArchive() {
byte[] notAZip = "this is definitely not a zip file".getBytes(StandardCharsets.UTF_8);
assertThatThrownBy(
() ->
CbzUtils.convertCbzToPdf(
cbz(notAZip), factory, tempFileManager, false))
.isInstanceOf(Exception.class);
}
@Test
@DisplayName("a CBZ whose only image is corrupt produces no pages and is rejected")
void corruptImageProducesNoPages() throws Exception {
byte[] archive =
buildCbz(
new String[] {"broken.png"},
new byte[][] {"not a real png".getBytes(StandardCharsets.UTF_8)});
assertThatThrownBy(
() ->
CbzUtils.convertCbzToPdf(
cbz(archive), factory, tempFileManager, false))
.isInstanceOf(Exception.class);
}
}
@Nested
@DisplayName("@TempDir cleanup")
class CleanupTests {
@Test
@DisplayName("the returned TempFile lives under the configured temp dir and closes cleanly")
void tempFileCleanup() throws Exception {
byte[] archive =
buildCbz(new String[] {"p.png"}, new byte[][] {pngBytes(Color.MAGENTA)});
TempFile resultPdf =
CbzUtils.convertCbzToPdf(cbz(archive), factory, tempFileManager, false);
Path path = resultPdf.getPath();
assertThat(Files.exists(path)).isTrue();
resultPdf.close();
assertThat(Files.exists(path)).isFalse();
}
}
}
@@ -0,0 +1,270 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.nio.charset.StandardCharsets;
import java.time.ZonedDateTime;
import java.util.Base64;
import java.util.Locale;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.util.EmlParser.EmailAttachment;
import stirling.software.common.util.EmlParser.EmailContent;
/**
* Gap-filling tests for {@link EmlParser#extractEmailContent} driven by small real .eml strings.
* These exercise the content-building, recipient-formatting and attachment-mapping branches plus
* the nested {@link EmailContent}/{@link EmailAttachment} value types. No network or external tool.
*/
class EmlParserMoreTest {
private static final String TS = "Mon, 01 Jan 2024 12:00:00 +0000";
private static byte[] eml(String content) {
return content.getBytes(StandardCharsets.UTF_8);
}
private static EmlToPdfRequest requestWithAttachments(int maxMb) {
EmlToPdfRequest request = new EmlToPdfRequest();
request.setIncludeAttachments(true);
request.setMaxAttachmentSizeMB(maxMb);
return request;
}
private static String simpleText(String from, String to, String subject, String body) {
return String.format(
Locale.ROOT,
"From: %s\nTo: %s\nSubject: %s\nDate: %s\n"
+ "Content-Type: text/plain; charset=UTF-8\n"
+ "Content-Transfer-Encoding: 8bit\n\n%s",
from,
to,
subject,
TS,
body);
}
private static String multipartWithAttachment(
String boundary, String body, String filename, String attachmentContent) {
String encoded =
Base64.getEncoder()
.encodeToString(attachmentContent.getBytes(StandardCharsets.UTF_8));
return String.format(
Locale.ROOT,
"From: a@example.com\nTo: b@example.com\nCc: c@example.com\n"
+ "Subject: Multipart\nDate: %s\n"
+ "Content-Type: multipart/mixed; boundary=\"%s\"\n\n"
+ "--%s\nContent-Type: text/plain; charset=UTF-8\n"
+ "Content-Transfer-Encoding: 8bit\n\n%s\n\n"
+ "--%s\nContent-Type: text/plain; charset=UTF-8\n"
+ "Content-Disposition: attachment; filename=\"%s\"\n"
+ "Content-Transfer-Encoding: base64\n\n%s\n\n--%s--",
TS,
boundary,
boundary,
body,
boundary,
filename,
encoded,
boundary);
}
@Nested
@DisplayName("extractEmailContent - headers and bodies")
class HeaderTests {
@Test
@DisplayName("subject, from, to and plain-text body are extracted")
void plainTextEmail() throws Exception {
EmailContent content =
EmlParser.extractEmailContent(
eml(
simpleText(
"sender@example.com",
"recipient@example.com",
"Hello Subject",
"Body line one")),
null,
null);
assertThat(content.getSubject()).isEqualTo("Hello Subject");
assertThat(content.getFrom()).contains("sender@example.com");
assertThat(content.getTo()).contains("recipient@example.com");
assertThat(content.getTextBody()).contains("Body line one");
}
@Test
@DisplayName("the sent date is parsed into a UTC ZonedDateTime")
void parsesDate() throws Exception {
EmailContent content =
EmlParser.extractEmailContent(
eml(simpleText("a@x.com", "b@x.com", "Dated", "hi")), null, null);
ZonedDateTime date = content.getDate();
assertThat(date).isNotNull();
assertThat(date.getYear()).isEqualTo(2024);
}
@Test
@DisplayName("an HTML body is captured as the html body")
void htmlBodyCaptured() throws Exception {
String html =
String.format(
Locale.ROOT,
"From: a@x.com\nTo: b@x.com\nSubject: HtmlMail\nDate: %s\n"
+ "Content-Type: text/html; charset=UTF-8\n"
+ "Content-Transfer-Encoding: 8bit\n\n"
+ "<html><body><p>Rich</p></body></html>",
TS);
EmailContent content = EmlParser.extractEmailContent(eml(html), null, null);
assertThat(content.getHtmlBody()).contains("Rich");
}
}
@Nested
@DisplayName("extractEmailContent - attachments")
class AttachmentTests {
@Test
@DisplayName("attachment metadata is mapped and CC recipients are formatted")
void attachmentMappedAndCc() throws Exception {
EmailContent content =
EmlParser.extractEmailContent(
eml(
multipartWithAttachment(
"----b1",
"see attached",
"notes.txt",
"attachment payload")),
requestWithAttachments(10),
null);
assertThat(content.getCc()).contains("c@example.com");
assertThat(content.getAttachmentCount()).isGreaterThanOrEqualTo(1);
EmailAttachment att = content.getAttachments().get(0);
assertThat(att.getFilename()).isEqualTo("notes.txt");
assertThat(att.getData()).isNotNull();
}
@Test
@DisplayName("when attachments are not requested the data bytes are omitted")
void attachmentDataOmittedWhenNotRequested() throws Exception {
EmlToPdfRequest noAttach = new EmlToPdfRequest();
noAttach.setIncludeAttachments(false);
EmailContent content =
EmlParser.extractEmailContent(
eml(
multipartWithAttachment(
"----b2", "body", "doc.txt", "some content")),
noAttach,
null);
// Metadata still present, but the raw bytes are not attached.
assertThat(content.getAttachmentCount()).isGreaterThanOrEqualTo(1);
assertThat(content.getAttachments().get(0).getData()).isNull();
}
@Test
@DisplayName("an attachment over the size limit has its data skipped")
void attachmentOverSizeLimitSkipped() throws Exception {
// 0 MB limit means any non-empty attachment exceeds it.
EmailContent content =
EmlParser.extractEmailContent(
eml(
multipartWithAttachment(
"----b3",
"body",
"big.txt",
"this content exceeds the zero-byte limit")),
requestWithAttachments(0),
null);
assertThat(content.getAttachments().get(0).getData()).isNull();
}
}
@Nested
@DisplayName("extractEmailContent - failure paths")
class FailureTests {
@Test
@DisplayName("OLE2 magic bytes that are not a real MSG file raise an IOException")
void fakeMsgFile() {
// OLE2/MSG magic prefix followed by garbage -> outlookMsgToEmail fails.
byte[] fakeMsg = {
(byte) 0xD0,
(byte) 0xCF,
(byte) 0x11,
(byte) 0xE0,
(byte) 0xA1,
(byte) 0xB1,
(byte) 0x1A,
(byte) 0xE1,
0x00,
0x01,
0x02,
0x03,
0x04,
0x05,
0x06,
0x07
};
assertThatThrownBy(() -> EmlParser.extractEmailContent(fakeMsg, null, null))
.isInstanceOf(java.io.IOException.class);
}
}
@Nested
@DisplayName("EmailContent value type")
class EmailContentTests {
@Test
@DisplayName("setHtmlBody and setTextBody strip carriage returns")
void stripsCarriageReturns() throws Exception {
EmailContent content =
EmlParser.extractEmailContent(
eml(simpleText("a@x.com", "b@x.com", "s", "x")), null, null);
content.setHtmlBody("line1\r\nline2");
content.setTextBody("a\r\nb");
assertThat(content.getHtmlBody()).doesNotContain("\r");
assertThat(content.getTextBody()).doesNotContain("\r");
}
@Test
@DisplayName("null bodies are preserved as null")
void nullBodiesPreserved() throws Exception {
EmailContent content =
EmlParser.extractEmailContent(
eml(simpleText("a@x.com", "b@x.com", "s", "x")), null, null);
content.setHtmlBody(null);
assertThat(content.getHtmlBody()).isNull();
}
}
@Nested
@DisplayName("EmailAttachment value type")
class EmailAttachmentTests {
@Test
@DisplayName("setData updates the size in bytes")
void setDataUpdatesSize() {
EmailAttachment att = new EmailAttachment();
att.setData(new byte[] {1, 2, 3, 4, 5});
assertThat(att.getSizeBytes()).isEqualTo(5);
}
@Test
@DisplayName("setData with null leaves size unchanged")
void setDataNull() {
EmailAttachment att = new EmailAttachment();
att.setData(null);
assertThat(att.getSizeBytes()).isZero();
}
}
}
@@ -0,0 +1,218 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
import stirling.software.common.util.EmlParser.EmailAttachment;
import stirling.software.common.util.EmlParser.EmailContent;
/**
* Gap-filling tests for the HTML-generation and helper methods of {@link EmlProcessingUtils}. All
* inputs are built in-memory; no sanitizer, network or external tool is used.
*/
class EmlProcessingUtilsMoreTest {
private static EmailContent content(String subject, String from, String to) {
EmailContent content = new EmailContent();
content.setSubject(subject);
content.setFrom(from);
content.setTo(to);
return content;
}
@Nested
@DisplayName("generateEnhancedEmailHtml")
class GenerateHtmlTests {
@Test
@DisplayName("produces a full HTML document with the subject and core headers")
void basicDocument() {
EmailContent content = content("My Subject", "from@x.com", "to@x.com");
content.setTextBody("plain body text");
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
assertThat(html)
.contains("<!DOCTYPE html>")
.contains("My Subject")
.contains("from@x.com")
.contains("to@x.com")
.contains("plain body text")
.contains("</body></html>");
}
@Test
@DisplayName("renders CC, BCC and a formatted date when present")
void ccBccAndDate() {
EmailContent content = content("Sub", "from@x.com", "to@x.com");
content.setCc("cc@x.com");
content.setBcc("bcc@x.com");
content.setDate(ZonedDateTime.of(2024, 5, 6, 7, 8, 0, 0, ZoneOffset.UTC));
content.setTextBody("hi");
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
assertThat(html)
.contains("CC:")
.contains("cc@x.com")
.contains("BCC:")
.contains("bcc@x.com")
.contains("Date:");
}
@Test
@DisplayName("prefers the HTML body over the text body when both are present")
void prefersHtmlBody() {
EmailContent content = content("Sub", "f@x.com", "t@x.com");
content.setHtmlBody("<p>html version</p>");
content.setTextBody("text version");
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
assertThat(html).contains("html version");
}
@Test
@DisplayName("falls back to a no-content placeholder when both bodies are empty")
void noContentPlaceholder() {
EmailContent content = content("Sub", "f@x.com", "t@x.com");
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
assertThat(html).contains("No content available");
}
@Test
@DisplayName("renders an attachments section and respects includeAttachments wording")
void attachmentsSection() {
EmailContent content = content("Sub", "f@x.com", "t@x.com");
content.setTextBody("body");
EmailAttachment att = new EmailAttachment();
att.setFilename("file.pdf");
att.setContentType("application/pdf");
att.setData(new byte[] {1, 2, 3});
List<EmailAttachment> list = new ArrayList<>();
list.add(att);
content.setAttachments(list);
content.setAttachmentCount(1);
EmlToPdfRequest request = new EmlToPdfRequest();
request.setIncludeAttachments(true);
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, request, null);
assertThat(html)
.contains("Attachments (1)")
.contains("file.pdf")
.contains("embedded in the file");
}
@Test
@DisplayName("shows the not-included note when attachments are not requested")
void attachmentsNotIncludedNote() {
EmailContent content = content("Sub", "f@x.com", "t@x.com");
content.setTextBody("body");
EmailAttachment att = new EmailAttachment();
att.setFilename("a.txt");
List<EmailAttachment> list = new ArrayList<>();
list.add(att);
content.setAttachments(list);
content.setAttachmentCount(1);
String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null);
assertThat(html).contains("files not included in PDF");
}
}
@Nested
@DisplayName("createHtmlRequest")
class CreateHtmlRequestTests {
@Test
@DisplayName("copies the file input and applies the default zoom")
void copiesFileInputAndZoom() {
EmlToPdfRequest request = new EmlToPdfRequest();
HTMLToPdfRequest htmlRequest = EmlProcessingUtils.createHtmlRequest(request);
assertThat(htmlRequest).isNotNull();
assertThat(htmlRequest.getZoom()).isEqualTo(1.0f);
}
@Test
@DisplayName("tolerates a null request and still sets the zoom")
void nullRequest() {
HTMLToPdfRequest htmlRequest = EmlProcessingUtils.createHtmlRequest(null);
assertThat(htmlRequest.getZoom()).isEqualTo(1.0f);
}
}
@Nested
@DisplayName("simplifyHtmlContent")
class SimplifyHtmlTests {
@Test
@DisplayName("strips script and style tags")
void stripsScriptAndStyle() {
String html =
"<html><head><style>.a{}</style></head>"
+ "<body><script>alert(1)</script><p>keep</p></body></html>";
String result = EmlProcessingUtils.simplifyHtmlContent(html);
assertThat(result).doesNotContain("<script").doesNotContain("<style").contains("keep");
}
}
@Nested
@DisplayName("decodeMimeHeader - quoted-printable charset handling")
class DecodeMimeHeaderTests {
@Test
@DisplayName("decodes a quoted-printable hex sequence into the right characters")
void decodesQpHex() {
// =E9 in ISO-8859-1 is 'é'.
String result = EmlProcessingUtils.decodeMimeHeader("=?ISO-8859-1?Q?caf=E9?=");
assertThat(result).isEqualTo("café");
}
@Test
@DisplayName("an unknown charset falls back without throwing")
void unknownCharsetFallback() {
String result = EmlProcessingUtils.decodeMimeHeader("=?MADE-UP-CHARSET?B?SGVsbG8=?=");
assertThat(result).isNotNull();
}
}
@Nested
@DisplayName("convertTextToHtml - sanitizer-less escaping")
class ConvertTextToHtmlTests {
@Test
@DisplayName("escapes HTML special characters when no sanitizer is supplied")
void escapesSpecialChars() {
String result = EmlProcessingUtils.convertTextToHtml("a <b> & c", null);
assertThat(result).contains("&lt;b&gt;").contains("&amp;");
}
}
@Nested
@DisplayName("detectMimeType - extension table")
class DetectMimeTypeTests {
@Test
@DisplayName("detects svg, bmp and webp from the filename")
void detectsExtraTypes() {
assertThat(EmlProcessingUtils.detectMimeType("a.svg", null)).isEqualTo("image/svg+xml");
assertThat(EmlProcessingUtils.detectMimeType("a.bmp", null)).isEqualTo("image/bmp");
assertThat(EmlProcessingUtils.detectMimeType("a.webp", null)).isEqualTo("image/webp");
}
}
}
@@ -0,0 +1,92 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import stirling.software.common.util.ExceptionUtils.CbrFormatException;
import stirling.software.common.util.ExceptionUtils.CbzFormatException;
import stirling.software.common.util.ExceptionUtils.ErrorCode;
import stirling.software.common.util.ExceptionUtils.FfmpegRequiredException;
import stirling.software.common.util.ExceptionUtils.GhostscriptException;
/**
* Remaining-gap tests for {@link ExceptionUtils} not already covered by ExceptionUtilsTest /
* ExceptionUtilsGapTest: the two-argument Ghostscript factory, the cause-bearing exception
* constructors, and the EPS-multipage Ghostscript diagnostic branch.
*/
class ExceptionUtilsExtraTest {
@Nested
@DisplayName("createGhostscriptCompressionException(processOutput, cause)")
class TwoArgGhostscriptTests {
@Test
@DisplayName("both output and cause provided yields a coded exception with the cause")
void outputAndCause() {
Exception cause = new RuntimeException("boom");
GhostscriptException ex =
ExceptionUtils.createGhostscriptCompressionException(
"Some informational chatter", cause);
assertSame(cause, ex.getCause());
assertEquals(ErrorCode.GHOSTSCRIPT_COMPRESSION.getCode(), ex.getErrorCode());
}
@Test
@DisplayName("EPS-multipage marker is recognized as a page-drawing error")
void epsMultipageMarker() {
String output = "Page 1\nEPS files may not contain multiple pages";
GhostscriptException ex = ExceptionUtils.createGhostscriptCompressionException(output);
assertEquals(ErrorCode.GHOSTSCRIPT_PAGE_DRAWING.getCode(), ex.getErrorCode());
assertNotNull(ex.getMessage());
}
@Test
@DisplayName("single-string overload with informational output uses compression code")
void singleStringInformational() {
GhostscriptException ex =
ExceptionUtils.createGhostscriptCompressionException("just chatter");
assertEquals(ErrorCode.GHOSTSCRIPT_COMPRESSION.getCode(), ex.getErrorCode());
// The fallback informative line is appended to the base message.
assertTrue(ex.getMessage().contains("chatter"));
}
}
@Nested
@DisplayName("cause-bearing exception constructors")
class CauseConstructorTests {
@Test
@DisplayName("CbrFormatException(message, cause, code) retains cause and code")
void cbrWithCause() {
Exception cause = new IllegalStateException("rar");
CbrFormatException ex = new CbrFormatException("bad cbr", cause, "E010");
assertSame(cause, ex.getCause());
assertEquals("E010", ex.getErrorCode());
assertEquals("bad cbr", ex.getMessage());
}
@Test
@DisplayName("CbzFormatException(message, code) leaves cause null")
void cbzNoCause() {
CbzFormatException ex = new CbzFormatException("bad cbz", "E015");
assertEquals("E015", ex.getErrorCode());
assertEquals("bad cbz", ex.getMessage());
}
@Test
@DisplayName("FfmpegRequiredException(message, cause, code) retains the cause")
void ffmpegWithCause() {
Exception cause = new RuntimeException("no ffmpeg");
FfmpegRequiredException ex =
new FfmpegRequiredException("ffmpeg missing", cause, "E063");
assertSame(cause, ex.getCause());
assertEquals("E063", ex.getErrorCode());
}
}
}
@@ -0,0 +1,162 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.function.Predicate;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import stirling.software.common.configuration.RuntimePathConfig;
/**
* Gap-coverage tests for {@link FileMonitor}, focusing on {@code isFileReadyForProcessing} branches
* (stale-timestamp ready path, active file-lock not-ready path) and {@code trackFiles} processing
* of real filesystem create/modify events. Timing-sensitive readiness is forced via explicit
* last-modified timestamps rather than sleeps to stay non-flaky.
*/
class FileMonitorMoreTest {
@TempDir Path tempDir;
private FileMonitor monitorWatching(Path watchDir, Predicate<Path> filter) throws IOException {
RuntimePathConfig config = mock(RuntimePathConfig.class);
when(config.getPipelineWatchedFoldersPaths()).thenReturn(List.of(watchDir.toString()));
return new FileMonitor(filter, config);
}
@Nested
@DisplayName("isFileReadyForProcessing")
class ReadinessTests {
@Test
@DisplayName("file with an old last-modified time and no lock is ready")
void staleFileIsReady() throws IOException {
FileMonitor monitor = monitorWatching(tempDir, p -> true);
Path file = tempDir.resolve("ready.pdf");
Files.writeString(file, "data");
// Backdate well beyond the 5000ms freshness window so the timestamp branch marks ready.
Files.setLastModifiedTime(
file, FileTime.from(Instant.now().minus(1, ChronoUnit.HOURS)));
assertTrue(monitor.isFileReadyForProcessing(file));
}
@Test
@DisplayName("stale file lock is acquired and released so readiness stays true")
void staleUnlockedFileLockRoundTrips() throws IOException {
FileMonitor monitor = monitorWatching(tempDir, p -> true);
Path file = tempDir.resolve("roundtrip.pdf");
Files.writeString(file, "data");
Files.setLastModifiedTime(
file, FileTime.from(Instant.now().minus(1, ChronoUnit.HOURS)));
// First call acquires+releases a lock and returns ready; a second call still works,
// proving the lock was released (no lingering handle).
assertTrue(monitor.isFileReadyForProcessing(file));
assertTrue(monitor.isFileReadyForProcessing(file));
}
@Test
@DisplayName("recently modified, unlocked file is not yet ready")
void freshFileNotReady() throws IOException {
FileMonitor monitor = monitorWatching(tempDir, p -> true);
Path file = tempDir.resolve("fresh.pdf");
Files.writeString(file, "data");
// Just-written file is within the freshness window and not in the ready list.
assertFalse(monitor.isFileReadyForProcessing(file));
}
}
@Nested
@DisplayName("trackFiles event processing")
class TrackFilesTests {
@Test
@DisplayName("pre-existing files are registered during construction")
void preExistingFilesRegistered() throws IOException {
Files.writeString(tempDir.resolve("existing.txt"), "x");
FileMonitor monitor = monitorWatching(tempDir, p -> true);
assertNotNull(monitor);
assertDoesNotThrow(monitor::trackFiles);
}
@Test
@DisplayName("pre-existing nested directories are registered recursively")
void nestedDirectoriesRegistered() throws IOException {
Path nested = tempDir.resolve("sub");
Files.createDirectories(nested);
Files.writeString(nested.resolve("inner.txt"), "y");
FileMonitor monitor = monitorWatching(tempDir, p -> true);
assertNotNull(monitor);
}
@Test
@DisplayName("create then modify then delete cycle is processed without error")
void createModifyDeleteCycle() throws IOException {
FileMonitor monitor = monitorWatching(tempDir, p -> true);
Path file = tempDir.resolve("cycle.txt");
Files.writeString(file, "one");
assertDoesNotThrow(monitor::trackFiles);
Files.writeString(file, "two-modified-content");
assertDoesNotThrow(monitor::trackFiles);
Files.delete(file);
assertDoesNotThrow(monitor::trackFiles);
}
@Test
@DisplayName("a rejecting path filter still lets trackFiles run cleanly")
void rejectingFilter() throws IOException {
FileMonitor monitor = monitorWatching(tempDir, p -> false);
Files.writeString(tempDir.resolve("ignored.txt"), "z");
assertDoesNotThrow(monitor::trackFiles);
}
@Test
@DisplayName("subdirectory created after start is handled on the next tick")
void subdirectoryCreatedAfterStart() throws IOException {
FileMonitor monitor = monitorWatching(tempDir, p -> true);
// First tick establishes monitoring; then create a child directory + file.
assertDoesNotThrow(monitor::trackFiles);
Path newDir = tempDir.resolve("late");
Files.createDirectories(newDir);
Files.writeString(newDir.resolve("late.txt"), "late");
assertDoesNotThrow(monitor::trackFiles);
}
}
@Nested
@DisplayName("re-registration safety net")
class ReRegistrationTests {
@Test
@DisplayName("trackFiles re-registers root dirs when nothing is currently mapped")
void reRegistersWhenEmpty() throws IOException {
// Root directory does not exist at construction, so nothing is registered.
Path missing = tempDir.resolve("appears-later");
FileMonitor monitor = monitorWatching(missing, p -> true);
// Now create the directory; the next tick should attempt re-registration.
Files.createDirectories(missing);
assertDoesNotThrow(monitor::trackFiles);
}
}
}
@@ -0,0 +1,288 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
/**
* Gap-filling tests for {@link FileToPdf#convertHtmlToPdf}. The WeasyPrint process is fully mocked
* via {@link MockedStatic} so the command-building, sanitization and ZIP repacking paths run
* without launching any external tool.
*/
class FileToPdfMoreTest {
private TempFileManager tempFileManager;
private CustomHtmlSanitizer sanitizer;
@TempDir Path tempDir;
@BeforeEach
void setUp() {
ApplicationProperties props = new ApplicationProperties();
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
props.getSystem().getTempFileManagement().setPrefix("test-htmlpdf-");
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
sanitizer = mock(CustomHtmlSanitizer.class);
// Identity sanitize so content is preserved for assertions.
when(sanitizer.sanitize(Mockito.anyString()))
.thenAnswer(invocation -> invocation.getArgument(0));
}
/** Build a real ZIP byte[] from name->content pairs. */
private static byte[] buildZip(String[] names, String[] contents) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
for (int i = 0; i < names.length; i++) {
zos.putNextEntry(new ZipEntry(names[i]));
zos.write(contents[i].getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
}
}
return baos.toByteArray();
}
/** mockStatic helper returning a captor of the command list passed to the executor. */
private ProcessExecutorResult successResult() {
ProcessExecutorResult result = mock(ProcessExecutorResult.class);
when(result.getRc()).thenReturn(0);
return result;
}
@Nested
@DisplayName("convertHtmlToPdf - HTML input")
class HtmlInputTests {
@Test
@SuppressWarnings("unchecked")
@DisplayName("builds the WeasyPrint command and returns the output bytes")
void htmlHappyPath() throws Exception {
ProcessExecutor executor = mock(ProcessExecutor.class);
ArgumentCaptor<List<String>> commandCaptor = ArgumentCaptor.forClass(List.class);
Mockito.doReturn(successResult())
.when(executor)
.runCommandWithOutputHandling(commandCaptor.capture());
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
.thenReturn(executor);
byte[] result =
FileToPdf.convertHtmlToPdf(
"/usr/bin/weasyprint",
new HTMLToPdfRequest(),
"<html><body>hi</body></html>".getBytes(StandardCharsets.UTF_8),
"page.html",
tempFileManager,
sanitizer);
assertThat(result).isNotNull();
List<String> command = commandCaptor.getValue();
assertThat(command.get(0)).isEqualTo("/usr/bin/weasyprint");
assertThat(command).contains("--pdf-forms", "-e", "utf-8");
}
}
@Test
@DisplayName("the HTML body is passed through the sanitizer before writing")
void htmlIsSanitized() throws Exception {
ProcessExecutor executor = mock(ProcessExecutor.class);
Mockito.doReturn(successResult())
.when(executor)
.runCommandWithOutputHandling(anyList());
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
.thenReturn(executor);
FileToPdf.convertHtmlToPdf(
"weasyprint",
new HTMLToPdfRequest(),
"<b>x</b>".getBytes(StandardCharsets.UTF_8),
"doc.HTML",
tempFileManager,
sanitizer);
Mockito.verify(sanitizer).sanitize("<b>x</b>");
}
}
}
@Nested
@DisplayName("convertHtmlToPdf - ZIP input")
class ZipInputTests {
@Test
@DisplayName("html entries inside the ZIP are sanitized and repacked")
void zipHtmlEntriesSanitized() throws Exception {
byte[] zip =
buildZip(
new String[] {"index.html", "asset.css"},
new String[] {"<p>body</p>", "p{color:red}"});
ProcessExecutor executor = mock(ProcessExecutor.class);
Mockito.doReturn(successResult())
.when(executor)
.runCommandWithOutputHandling(anyList());
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
.thenReturn(executor);
byte[] result =
FileToPdf.convertHtmlToPdf(
"weasyprint",
new HTMLToPdfRequest(),
zip,
"bundle.zip",
tempFileManager,
sanitizer);
assertThat(result).isNotNull();
// Only the .html entry should be sanitized, not the .css.
Mockito.verify(sanitizer).sanitize("<p>body</p>");
Mockito.verify(sanitizer, Mockito.never()).sanitize("p{color:red}");
}
}
@Test
@DisplayName("non-html entries inside the ZIP are copied through unchanged")
void zipNonHtmlCopied() throws Exception {
byte[] zip = buildZip(new String[] {"data.txt"}, new String[] {"plain text content"});
ProcessExecutor executor = mock(ProcessExecutor.class);
Mockito.doReturn(successResult())
.when(executor)
.runCommandWithOutputHandling(anyList());
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
.thenReturn(executor);
// Drop the identity-stub invocation recorded during setUp.
Mockito.clearInvocations(sanitizer);
byte[] result =
FileToPdf.convertHtmlToPdf(
"weasyprint",
new HTMLToPdfRequest(),
zip,
"bundle.zip",
tempFileManager,
sanitizer);
assertThat(result).isNotNull();
Mockito.verifyNoInteractions(sanitizer);
}
}
}
@Nested
@DisplayName("convertHtmlToPdf - invalid input")
class InvalidInputTests {
@Test
@DisplayName("an unsupported extension throws before any process is started")
void unsupportedExtension() {
assertThatThrownBy(
() ->
FileToPdf.convertHtmlToPdf(
"weasyprint",
new HTMLToPdfRequest(),
"data".getBytes(StandardCharsets.UTF_8),
"document.txt",
tempFileManager,
sanitizer))
.isInstanceOf(IllegalArgumentException.class);
}
}
@Nested
@DisplayName("sanitizeZipFilename additional branches")
class SanitizeZipFilenameTests {
@Test
@DisplayName("a bare relative name is returned unchanged")
void plainName() {
assertThat(FileToPdf.sanitizeZipFilename("file.html")).isEqualTo("file.html");
}
@Test
@DisplayName("only the .. sequences are stripped, the rest of the path survives")
void stripsTraversalKeepsTail() {
String result = FileToPdf.sanitizeZipFilename("a/../b/c.html");
assertThat(result).doesNotContain("..").endsWith("c.html");
}
}
@Nested
@DisplayName("repacked ZIP integrity")
class RepackedZipTests {
@Test
@DisplayName("the temp input zip handed to weasyprint still contains the html entry")
void repackedZipContainsEntry() throws Exception {
byte[] zip = buildZip(new String[] {"a.html"}, new String[] {"<i>hi</i>"});
// Inspect the repacked zip from inside the command answer, while the temp file is
// still on disk (it is auto-deleted once convertHtmlToPdf returns).
List<String> entryNames = new java.util.ArrayList<>();
ProcessExecutor executor = mock(ProcessExecutor.class);
Mockito.doAnswer(
invocation -> {
List<String> command = invocation.getArgument(0);
Path inputZip = Path.of(command.get(command.size() - 2));
try (ZipInputStream zis =
new ZipInputStream(
java.nio.file.Files.newInputStream(inputZip))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
entryNames.add(entry.getName());
}
}
return successResult();
})
.when(executor)
.runCommandWithOutputHandling(anyList());
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
.thenReturn(executor);
FileToPdf.convertHtmlToPdf(
"weasyprint",
new HTMLToPdfRequest(),
zip,
"bundle.zip",
tempFileManager,
sanitizer);
assertThat(entryNames).anyMatch(name -> name.endsWith("a.html"));
}
}
}
}
@@ -0,0 +1,655 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.FormFieldWithCoordinates;
/**
* Additional branch coverage for {@link FormUtils}, complementing FormUtilsAdditionalTest and
* FormUtilsGapTest. Targets the display-label derivation chain, choice/radio value extraction and
* application, the modify-form type-change recreation path, and coordinate edge cases.
*/
class FormUtilsMoreTest {
private record SetupDocument(PDPage page, PDAcroForm acroForm) {}
private static SetupDocument createBasicDocument(PDDocument document) {
PDPage page = new PDPage(PDRectangle.A4);
document.addPage(page);
PDAcroForm acroForm = new PDAcroForm(document);
PDResources dr = new PDResources();
dr.put(COSName.getPDFName("Helv"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
acroForm.setDefaultResources(dr);
acroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
acroForm.setNeedAppearances(true);
document.getDocumentCatalog().setAcroForm(acroForm);
return new SetupDocument(page, acroForm);
}
private static void attachWidget(
SetupDocument setup, PDTerminalField field, PDRectangle rectangle) throws IOException {
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(rectangle);
widget.setPage(setup.page());
List<PDAnnotationWidget> widgets = new ArrayList<>();
widgets.add(widget);
field.setWidgets(widgets);
setup.acroForm().getFields().add(field);
setup.page().getAnnotations().add(widget);
}
// ----------------------------------------------------------------------
// extractFormFields - field-type branches and display labels
// ----------------------------------------------------------------------
@Nested
@DisplayName("extractFormFields metadata")
class ExtractFormFieldsMetadata {
@Test
void comboBoxExtractsOptionsAndType() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDComboBox combo = new PDComboBox(setup.acroForm());
combo.setPartialName("color");
combo.setOptions(List.of("Red", "Green"));
attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals(1, fields.size());
FormUtils.FormFieldInfo info = fields.get(0);
assertEquals("combobox", info.type());
assertNotNull(info.options());
assertTrue(info.options().contains("Red"));
}
}
@Test
void multiSelectListBoxReportsMultiSelect() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDListBox listBox = new PDListBox(setup.acroForm());
listBox.setPartialName("items");
listBox.setMultiSelect(true);
listBox.setOptions(List.of("A", "B", "C"));
attachWidget(setup, listBox, new PDRectangle(50, 600, 200, 60));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals(1, fields.size());
assertEquals("listbox", fields.get(0).type());
assertTrue(fields.get(0).multiSelect());
}
}
@Test
void fieldWithoutNameIsSkipped() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
// No partial name set -> fullyQualifiedName and partialName both null -> skipped.
PDTextField nameless = new PDTextField(setup.acroForm());
attachWidget(setup, nameless, new PDRectangle(50, 700, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertTrue(fields.isEmpty());
}
}
@Test
void alternateFieldNameBecomesDisplayLabel() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("f1");
text.setAlternateFieldName("Customer Email");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals("Customer Email", fields.get(0).label());
}
}
@Test
void tooltipBecomesDisplayLabelWhenNoAlternate() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("f1");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
// Set the /TU tooltip on the widget.
text.getWidgets().get(0).getCOSObject().setString(COSName.TU, "Phone Number");
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals("Phone Number", fields.get(0).label());
assertEquals("Phone Number", fields.get(0).tooltip());
}
}
@Test
void humanizedNameUsedWhenNoLabelSources() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("first_name");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
// humanizeName turns first_name -> "first name".
assertEquals("first name", fields.get(0).label());
}
}
@Test
void genericNameFallsBackToTypeLabel() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
// A 32+ hex char name is detected as UUID-like (generic), forcing the fallback.
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("cdc47b7041524571abcd93017fe77bf7");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals("Text field 1", fields.get(0).label());
}
}
@Test
void choiceFieldCurrentValueIsJoined() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDListBox listBox = new PDListBox(setup.acroForm());
listBox.setPartialName("items");
listBox.setMultiSelect(true);
listBox.setOptions(List.of("A", "B", "C"));
attachWidget(setup, listBox, new PDRectangle(50, 600, 200, 60));
listBox.setValue(List.of("A", "C"));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals("A,C", fields.get(0).value());
}
}
@Test
void fieldsAreSortedByPageThenOrderThenName() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField zebra = new PDTextField(setup.acroForm());
zebra.setPartialName("zebra");
attachWidget(setup, zebra, new PDRectangle(50, 700, 200, 20));
PDTextField apple = new PDTextField(setup.acroForm());
apple.setPartialName("apple");
attachWidget(setup, apple, new PDRectangle(50, 660, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals(2, fields.size());
// pageOrder is assigned in tree order so zebra (added first) keeps order 0.
assertEquals("zebra", fields.get(0).name());
assertEquals(0, fields.get(0).pageOrder());
assertEquals(1, fields.get(1).pageOrder());
}
}
}
// ----------------------------------------------------------------------
// extractFormFieldsWithCoordinates - extra branches
// ----------------------------------------------------------------------
@Nested
@DisplayName("extractFormFieldsWithCoordinates extras")
class ExtractWithCoordinatesExtras {
@Test
void multilineAndReadOnlyFlagsAreReported() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("notes");
text.setMultiline(true);
text.setReadOnly(true);
attachWidget(setup, text, new PDRectangle(50, 600, 200, 80));
List<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(doc);
assertEquals(1, fields.size());
assertTrue(fields.get(0).isMultiline());
assertTrue(fields.get(0).isReadOnly());
}
}
@Test
void comboBoxWithDistinctDisplayValuesPopulatesDisplayOptions() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDComboBox combo = new PDComboBox(setup.acroForm());
combo.setPartialName("country");
// Distinct export vs display values triggers displayOptions to be sent.
combo.setOptions(List.of("US", "GB"), List.of("United States", "Britain"));
attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20));
List<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(doc);
assertEquals(1, fields.size());
List<String> displayOptions = fields.get(0).getDisplayOptions();
assertNotNull(displayOptions);
assertTrue(displayOptions.contains("United States"));
}
}
@Test
void fontSizeExtractedFromDefaultAppearance() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("sized");
text.setDefaultAppearance("/Helv 14 Tf 0 g");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
List<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(doc);
FormFieldWithCoordinates.WidgetCoordinates wc = fields.get(0).getWidgets().get(0);
assertEquals(14f, wc.getFontSize(), 0.01f);
}
}
@Test
void widgetOutOfBoundsYieldsNullCoordinateEntry() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("offpage");
// Far below the page origin -> finalY exceeds bounds -> createWidgetCoordinates
// returns null, which is still added to the per-field widget list.
attachWidget(setup, text, new PDRectangle(50, -5000, 200, 20));
List<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(doc);
assertEquals(1, fields.size());
List<FormFieldWithCoordinates.WidgetCoordinates> widgets =
fields.get(0).getWidgets();
assertNotNull(widgets);
assertEquals(1, widgets.size());
assertNull(widgets.get(0));
}
}
@Test
void widgetWithNullRectangleIsSkipped() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("norect");
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setPage(setup.page());
// Deliberately leave rectangle unset.
List<PDAnnotationWidget> widgets = new ArrayList<>();
widgets.add(widget);
text.setWidgets(widgets);
setup.acroForm().getFields().add(text);
setup.page().getAnnotations().add(widget);
List<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(doc);
assertEquals(1, fields.size());
assertNull(fields.get(0).getWidgets());
}
}
}
// ----------------------------------------------------------------------
// applyFieldValues - choice / radio / signature / button branches
// ----------------------------------------------------------------------
@Nested
@DisplayName("applyFieldValues field-type branches")
class ApplyFieldValuesBranches {
@Test
void comboBoxValueIsApplied() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDComboBox combo = new PDComboBox(setup.acroForm());
combo.setPartialName("color");
combo.setOptions(List.of("Red", "Green", "Blue"));
attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20));
FormUtils.applyFieldValues(doc, Map.of("color", "Green"), false);
assertThat(combo.getValue()).contains("Green");
}
}
@Test
void comboBoxNullValueClearsSelection() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDComboBox combo = new PDComboBox(setup.acroForm());
combo.setPartialName("color");
combo.setOptions(List.of("Red", "Green"));
attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20));
combo.setValue("Red");
java.util.Map<String, Object> values = new java.util.HashMap<>();
values.put("color", null);
FormUtils.applyFieldValues(doc, values, false);
// Null value routes to setValue("") which clears the prior "Red" selection.
assertFalse(combo.getValue().contains("Red"));
}
}
@Test
void multiSelectListBoxAppliesCommaSeparatedValues() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDListBox listBox = new PDListBox(setup.acroForm());
listBox.setPartialName("items");
listBox.setMultiSelect(true);
listBox.setOptions(List.of("A", "B", "C"));
attachWidget(setup, listBox, new PDRectangle(50, 600, 200, 60));
FormUtils.applyFieldValues(doc, Map.of("items", "A, C"), false);
assertThat(listBox.getValue()).containsExactlyInAnyOrder("A", "C");
}
}
@Test
void radioButtonValueIsApplied() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDListBox other = new PDListBox(setup.acroForm());
other.setPartialName("dummy");
other.setOptions(List.of("x"));
attachWidget(setup, other, new PDRectangle(50, 500, 200, 20));
// Blank radio value path: no exception, value stays unset.
org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton radio =
new org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton(
setup.acroForm());
radio.setPartialName("choice");
attachWidget(setup, radio, new PDRectangle(50, 700, 20, 20));
FormUtils.applyFieldValues(doc, Map.of("choice", " "), false);
// No widgets configured with on-states, but the blank-skip branch must not throw.
assertNotNull(radio.getValueAsString());
}
}
@Test
void signatureAndPushButtonFieldsAreSkipped() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDSignatureField sig = new PDSignatureField(setup.acroForm());
sig.setPartialName("sig");
attachWidget(setup, sig, new PDRectangle(50, 700, 200, 40));
PDPushButton button = new PDPushButton(setup.acroForm());
button.setPartialName("btn");
attachWidget(setup, button, new PDRectangle(50, 640, 200, 40));
// Must complete without throwing; both branches are no-ops.
FormUtils.applyFieldValues(doc, Map.of("sig", "ignored", "btn", "ignored"), false);
}
}
@Test
void blankKeysAreSkipped() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("name");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
java.util.Map<String, Object> values = new java.util.LinkedHashMap<>();
values.put(" ", "blankKey");
values.put("name", "value");
FormUtils.applyFieldValues(doc, values, false);
assertEquals("value", text.getValueAsString());
}
}
@Test
void unknownKeyIsSkipped() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("name");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
FormUtils.applyFieldValues(doc, Map.of("doesNotExist", "x"), false);
assertEquals("", text.getValueAsString());
}
}
}
// ----------------------------------------------------------------------
// modifyFormFields - type change (recreate) and choice in-place edits
// ----------------------------------------------------------------------
@Nested
@DisplayName("modifyFormFields advanced")
class ModifyFormFieldsAdvanced {
@Test
void changesFieldTypeViaRecreate() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("toCombo");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"toCombo",
"toCombo",
"Pick one",
"combobox",
null,
null,
List.of("One", "Two"),
"One",
null);
FormUtils.modifyFormFields(doc, List.of(mod));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals(1, fields.size());
assertEquals("combobox", fields.get(0).type());
assertEquals("toCombo", fields.get(0).name());
}
}
@Test
void inPlaceChoiceOptionAndMultiSelectUpdate() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDListBox listBox = new PDListBox(setup.acroForm());
listBox.setPartialName("list");
listBox.setOptions(List.of("A", "B"));
attachWidget(setup, listBox, new PDRectangle(50, 600, 200, 60));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"list",
null,
null,
"listbox", // same type -> in-place path
null,
Boolean.TRUE,
List.of("X", "Y", "Z"),
null,
"Choose items");
FormUtils.modifyFormFields(doc, List.of(mod));
PDField updated = doc.getDocumentCatalog().getAcroForm().getField("list");
assertTrue(updated instanceof PDListBox);
assertTrue(((PDListBox) updated).isMultiSelect());
assertThat(((PDListBox) updated).getOptions()).contains("X", "Y", "Z");
}
}
@Test
void unsupportedTargetTypeIsSkipped() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("keep");
attachWidget(setup, text, new PDRectangle(50, 700, 200, 20));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"keep", null, null, "bogusType", null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
// The field is preserved unchanged because the target type is unsupported.
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(doc);
assertEquals(1, fields.size());
assertEquals("text", fields.get(0).type());
}
}
@Test
void renameAvoidsCollisionWithExistingField() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField a = new PDTextField(setup.acroForm());
a.setPartialName("alpha");
attachWidget(setup, a, new PDRectangle(50, 700, 200, 20));
PDTextField b = new PDTextField(setup.acroForm());
b.setPartialName("beta");
attachWidget(setup, b, new PDRectangle(50, 660, 200, 20));
// Rename beta -> alpha; should be uniquified to avoid the collision.
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"beta", "alpha", null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
List<String> names = new ArrayList<>();
for (FormUtils.FormFieldInfo info : FormUtils.extractFormFields(doc)) {
names.add(info.name());
}
assertEquals(2, names.size());
assertTrue(names.contains("alpha"));
// The renamed field cannot also be "alpha"; it gets a suffix.
assertTrue(names.stream().anyMatch(n -> n.startsWith("alpha_")));
}
}
@Test
void documentWithoutAcroFormIsNoOp() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"x", null, null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
}
}
}
// ----------------------------------------------------------------------
// buildFillTemplateRecord - radio default branch
// ----------------------------------------------------------------------
@Test
void buildFillTemplateRadioUsesCurrentValue() {
FormUtils.FormFieldInfo info =
new FormUtils.FormFieldInfo(
"choice", "Choice", "radio", "Yes", null, false, 0, false, null, 0);
Map<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
assertEquals("Yes", result.get("choice"));
}
@Test
void buildFillTemplateNullEntriesAreSkipped() {
List<FormUtils.FormFieldInfo> list = new ArrayList<>();
list.add(null);
list.add(
new FormUtils.FormFieldInfo(
"kept", "Kept", "text", "v", null, false, 0, false, null, 0));
Map<String, Object> result = FormUtils.buildFillTemplateRecord(list);
assertEquals(1, result.size());
assertTrue(result.containsKey("kept"));
}
// ----------------------------------------------------------------------
// resolveDisplayOptions / resolveOptions extra branches
// ----------------------------------------------------------------------
@Test
void resolveDisplayOptionsReturnsDistinctDisplayValues() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDComboBox combo = new PDComboBox(setup.acroForm());
combo.setPartialName("c");
combo.setOptions(List.of("US", "GB"), List.of("United States", "Britain"));
List<String> display = FormUtils.resolveDisplayOptions(combo);
assertThat(display).contains("United States", "Britain");
}
}
@Test
void resolveOptionsRadioUsesExportValues() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton radio =
new org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton(setup.acroForm());
radio.setExportValues(List.of("opt1", "opt2"));
assertEquals(List.of("opt1", "opt2"), FormUtils.resolveOptions(radio));
}
}
// ----------------------------------------------------------------------
// applyFieldValues strict mode
// ----------------------------------------------------------------------
@Test
void strictModeWrapsChoiceFailureInIoException() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
// A combo box with no /Opt array: setting a non-empty value triggers the
// "missing /Opt" IllegalArgumentException, which strict mode rethrows as IOException.
PDComboBox combo = new PDComboBox(setup.acroForm());
combo.setPartialName("noOpts");
attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20));
assertThrows(
IOException.class,
() -> FormUtils.applyFieldValues(doc, Map.of("noOpts", "X"), false, true));
}
}
}
@@ -0,0 +1,335 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Branch coverage for {@link GeneralFormCopyUtils#copyAndTransformFormFields} and the {@link
* GeneralFormFieldTypeSupport} handlers, complementing GeneralFormCopyUtilsTest which only covers
* rotation and the empty-form early returns.
*/
class GeneralFormCopyUtilsMoreTest {
private static PDAcroForm newAcroForm(PDDocument document) {
PDAcroForm acroForm = new PDAcroForm(document);
PDResources dr = new PDResources();
dr.put(COSName.getPDFName("Helv"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
acroForm.setDefaultResources(dr);
acroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
document.getDocumentCatalog().setAcroForm(acroForm);
return acroForm;
}
private static void addWidget(PDTerminalField field, PDPage page, PDRectangle rect)
throws IOException {
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(rect);
widget.setPage(page);
List<PDAnnotationWidget> widgets = new ArrayList<>();
widgets.add(widget);
field.setWidgets(widgets);
page.getAnnotations().add(widget);
}
// ----------------------------------------------------------------------
// copyAndTransformFormFields - real field copying
// ----------------------------------------------------------------------
@Nested
@DisplayName("copyAndTransformFormFields copying")
class CopyingFields {
@Test
void copiesTextCheckboxAndComboFields() throws IOException {
try (PDDocument source = new PDDocument();
PDDocument target = new PDDocument()) {
PDPage sourcePage = new PDPage(PDRectangle.A4);
source.addPage(sourcePage);
target.addPage(new PDPage(PDRectangle.A4));
PDAcroForm sourceForm = newAcroForm(source);
PDTextField text = new PDTextField(sourceForm);
text.setPartialName("name");
addWidget(text, sourcePage, new PDRectangle(50, 700, 200, 20));
sourceForm.getFields().add(text);
text.setValue("Alice");
PDCheckBox check = new PDCheckBox(sourceForm);
check.setPartialName("agree");
check.setExportValues(List.of("Yes"));
addWidget(check, sourcePage, new PDRectangle(50, 660, 16, 16));
sourceForm.getFields().add(check);
PDComboBox combo = new PDComboBox(sourceForm);
combo.setPartialName("color");
addWidget(combo, sourcePage, new PDRectangle(50, 620, 200, 20));
sourceForm.getFields().add(combo);
combo.setOptions(List.of("Red", "Green"));
GeneralFormCopyUtils.copyAndTransformFormFields(
source, target, 1, 1, 1, 1, 612f, 792f);
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
assertNotNull(targetForm);
assertEquals(3, targetForm.getFields().size());
List<String> names = new ArrayList<>();
for (var f : targetForm.getFields()) {
names.add(f.getPartialName());
}
// Names are prefixed with page index during copy.
assertThat(names).contains("page0_name", "page0_agree", "page0_color");
}
}
@Test
void copiesFieldThroughMultiCellGridLayout() throws IOException {
try (PDDocument source = new PDDocument();
PDDocument target = new PDDocument()) {
PDPage sourcePage = new PDPage(PDRectangle.A4);
source.addPage(sourcePage);
target.addPage(new PDPage(PDRectangle.A4));
PDAcroForm sourceForm = newAcroForm(source);
PDTextField text = new PDTextField(sourceForm);
text.setPartialName("name");
addWidget(text, sourcePage, new PDRectangle(100, 100, 200, 20));
sourceForm.getFields().add(text);
// 2x2 layout exercises the scale/offset arithmetic for cell placement.
GeneralFormCopyUtils.copyAndTransformFormFields(
source, target, 1, 4, 2, 2, 300f, 396f);
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
assertEquals(1, targetForm.getFields().size());
assertEquals("page0_name", targetForm.getFields().get(0).getPartialName());
assertEquals(1, targetForm.getFields().get(0).getWidgets().size());
}
}
@Test
void skipsPagesWithoutAnnotations() throws IOException {
try (PDDocument source = new PDDocument();
PDDocument target = new PDDocument()) {
source.addPage(new PDPage(PDRectangle.A4)); // no annotations
target.addPage(new PDPage(PDRectangle.A4));
// Source has an AcroForm with a field on a different (non-existent here) page,
// but page 0 has no annotations -> the per-page copy is skipped.
PDAcroForm sourceForm = newAcroForm(source);
PDTextField text = new PDTextField(sourceForm);
text.setPartialName("ghost");
sourceForm.getFields().add(text);
GeneralFormCopyUtils.copyAndTransformFormFields(
source, target, 1, 1, 1, 1, 612f, 792f);
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
// Form is created but no widgets were copied.
assertNotNull(targetForm);
assertTrue(targetForm.getFields().isEmpty());
}
}
@Test
void skipsWhenRowIndexExceedsRows() throws IOException {
try (PDDocument source = new PDDocument();
PDDocument target = new PDDocument()) {
PDPage page0 = new PDPage(PDRectangle.A4);
PDPage page1 = new PDPage(PDRectangle.A4);
source.addPage(page0);
source.addPage(page1);
target.addPage(new PDPage(PDRectangle.A4));
PDAcroForm sourceForm = newAcroForm(source);
PDTextField a = new PDTextField(sourceForm);
a.setPartialName("a");
addWidget(a, page0, new PDRectangle(10, 10, 100, 20));
sourceForm.getFields().add(a);
PDTextField b = new PDTextField(sourceForm);
b.setPartialName("b");
addWidget(b, page1, new PDRectangle(10, 10, 100, 20));
sourceForm.getFields().add(b);
// cols=1, rows=1, pagesPerSheet=2 -> second page maps to rowIndex 1 (>= rows) ->
// skipped.
GeneralFormCopyUtils.copyAndTransformFormFields(
source, target, 2, 2, 1, 1, 612f, 792f);
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
assertEquals(1, targetForm.getFields().size());
assertEquals("page0_a", targetForm.getFields().get(0).getPartialName());
}
}
@Test
void skipsWhenDestinationPageMissing() throws IOException {
try (PDDocument source = new PDDocument();
PDDocument target = new PDDocument()) {
PDPage sourcePage = new PDPage(PDRectangle.A4);
source.addPage(sourcePage);
// Target has NO pages, so destinationPageIndex 0 is out of bounds.
PDAcroForm sourceForm = newAcroForm(source);
PDTextField text = new PDTextField(sourceForm);
text.setPartialName("name");
addWidget(text, sourcePage, new PDRectangle(50, 700, 200, 20));
sourceForm.getFields().add(text);
assertDoesNotThrow(
() ->
GeneralFormCopyUtils.copyAndTransformFormFields(
source, target, 1, 1, 1, 1, 612f, 792f));
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
assertTrue(targetForm.getFields().isEmpty());
}
}
@Test
void uniquifiesDuplicateFieldNamesAcrossPages() throws IOException {
try (PDDocument source = new PDDocument();
PDDocument target = new PDDocument()) {
PDPage sourcePage = new PDPage(PDRectangle.A4);
source.addPage(sourcePage);
target.addPage(new PDPage(PDRectangle.A4));
PDAcroForm sourceForm = newAcroForm(source);
// Two separate fields placed on the same source page with the same partial name
// would clash; the copier must generate distinct names.
PDTextField one = new PDTextField(sourceForm);
one.setPartialName("dup");
addWidget(one, sourcePage, new PDRectangle(50, 700, 100, 20));
sourceForm.getFields().add(one);
PDTextField two = new PDTextField(sourceForm);
two.setPartialName("dup");
addWidget(two, sourcePage, new PDRectangle(50, 660, 100, 20));
sourceForm.getFields().add(two);
GeneralFormCopyUtils.copyAndTransformFormFields(
source, target, 1, 1, 1, 1, 612f, 792f);
PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm();
assertEquals(2, targetForm.getFields().size());
List<String> names = new ArrayList<>();
for (var f : targetForm.getFields()) {
names.add(f.getPartialName());
}
// First keeps page0_dup; the second is suffixed.
assertTrue(names.contains("page0_dup"));
assertTrue(names.stream().anyMatch(n -> n.startsWith("page0_dup_")));
}
}
}
// ----------------------------------------------------------------------
// GeneralFormFieldTypeSupport - forField / createField / copyFromOriginal
// ----------------------------------------------------------------------
@Nested
@DisplayName("GeneralFormFieldTypeSupport")
class TypeSupport {
@Test
void forFieldNullReturnsNull() {
assertNull(GeneralFormFieldTypeSupport.forField(null));
}
@Test
void forFieldResolvesEachConcreteType() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = newAcroForm(doc);
assertEquals(
GeneralFormFieldTypeSupport.TEXT,
GeneralFormFieldTypeSupport.forField(new PDTextField(form)));
assertEquals(
GeneralFormFieldTypeSupport.CHECKBOX,
GeneralFormFieldTypeSupport.forField(new PDCheckBox(form)));
assertEquals(
GeneralFormFieldTypeSupport.COMBOBOX,
GeneralFormFieldTypeSupport.forField(new PDComboBox(form)));
assertEquals(
GeneralFormFieldTypeSupport.BUTTON,
GeneralFormFieldTypeSupport.forField(new PDPushButton(form)));
}
}
@Test
void createFieldProducesMatchingInstance() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = newAcroForm(doc);
PDTerminalField text = GeneralFormFieldTypeSupport.TEXT.createField(form);
assertTrue(text instanceof PDTextField);
PDTerminalField check = GeneralFormFieldTypeSupport.CHECKBOX.createField(form);
assertTrue(check instanceof PDCheckBox);
}
}
@Test
void copyFromOriginalTransfersComboOptions() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = newAcroForm(doc);
PDComboBox src = new PDComboBox(form);
src.setPartialName("src");
src.setOptions(List.of("A", "B"));
PDComboBox dst = new PDComboBox(form);
dst.setPartialName("dst");
GeneralFormFieldTypeSupport.COMBOBOX.copyFromOriginal(src, dst);
assertThat(dst.getOptions()).contains("A", "B");
}
}
@Test
void copyFromOriginalTransfersTextValue() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDAcroForm form = newAcroForm(doc);
PDTextField src = new PDTextField(form);
src.setPartialName("src");
src.setValue("hello");
PDTextField dst = new PDTextField(form);
dst.setPartialName("dst");
dst.setDefaultAppearance("/Helv 12 Tf 0 g");
GeneralFormFieldTypeSupport.TEXT.copyFromOriginal(src, dst);
assertEquals("hello", dst.getValueAsString());
}
}
@Test
void typeNameAndFallbackWidgetNameExposed() {
assertEquals("text", GeneralFormFieldTypeSupport.TEXT.typeName());
assertEquals("textField", GeneralFormFieldTypeSupport.TEXT.fallbackWidgetName());
assertEquals("checkbox", GeneralFormFieldTypeSupport.CHECKBOX.typeName());
}
}
}
@@ -0,0 +1,114 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
import stirling.software.common.util.GeneralUtils.NetworkInterfaceInfo;
class GeneralUtilsLocalIpTest {
private static NetworkInterfaceInfo iface(
String name, String displayName, int index, boolean virtual, String... ips) {
return new NetworkInterfaceInfo(
name, displayName, index, true, false, false, virtual, true, List.of(ips));
}
@Test
void prefersPhysicalWifiOverVmwareNatAdapter() {
NetworkInterfaceInfo vmware =
iface("eth5", "VMware Virtual Ethernet Adapter for VMnet8", 5, false, "172.16.1.1");
NetworkInterfaceInfo wifi =
iface("wlan0", "Intel(R) Wi-Fi 6 AX201", 12, false, "192.168.1.50");
assertEquals("192.168.1.50", GeneralUtils.selectBestSiteLocalIp(List.of(vmware, wifi)));
}
@Test
void excludesHyperVVethernetAdapter() {
NetworkInterfaceInfo hyperv =
iface("ethernet_32770", "Hyper-V Virtual Ethernet Adapter", 3, false, "172.28.0.1");
NetworkInterfaceInfo ethernet =
iface("eth0", "Realtek PCIe GbE Family Controller", 8, false, "192.168.0.20");
assertEquals("192.168.0.20", GeneralUtils.selectBestSiteLocalIp(List.of(hyperv, ethernet)));
}
@Test
void excludesWslAndDockerBridges() {
NetworkInterfaceInfo wsl =
iface("eth1", "Hyper-V Virtual Ethernet Adapter (WSL)", 70, false, "172.20.0.1");
NetworkInterfaceInfo docker = iface("docker0", "docker0", 4, false, "172.17.0.1");
NetworkInterfaceInfo lan =
iface("eth0", "Intel(R) Ethernet Connection", 2, false, "10.0.0.5");
assertEquals("10.0.0.5", GeneralUtils.selectBestSiteLocalIp(List.of(wsl, docker, lan)));
}
@Test
void prefers192Over10WhenBothPhysical() {
NetworkInterfaceInfo ten = iface("eth0", "Ethernet", 2, false, "10.1.2.3");
NetworkInterfaceInfo home = iface("wlan0", "Wi-Fi", 6, false, "192.168.1.10");
assertEquals("192.168.1.10", GeneralUtils.selectBestSiteLocalIp(List.of(ten, home)));
}
@Test
void breaksTiesByLowestInterfaceIndex() {
NetworkInterfaceInfo first = iface("eth0", "Ethernet", 2, false, "192.168.1.2");
NetworkInterfaceInfo second = iface("eth1", "Ethernet", 9, false, "192.168.1.3");
assertEquals("192.168.1.2", GeneralUtils.selectBestSiteLocalIp(List.of(second, first)));
}
@Test
void returnsNullWhenOnlyVirtualOrDownInterfaces() {
NetworkInterfaceInfo vbox =
iface("vboxnet0", "VirtualBox Host-Only Network", 1, false, "192.168.56.1");
NetworkInterfaceInfo flaggedVirtual =
new NetworkInterfaceInfo(
"eth9",
"Ethernet",
9,
true,
false,
false,
true,
true,
List.of("192.168.1.9"));
NetworkInterfaceInfo down =
new NetworkInterfaceInfo(
"eth0",
"Ethernet",
2,
false,
false,
false,
false,
true,
List.of("192.168.1.2"));
assertNull(GeneralUtils.selectBestSiteLocalIp(List.of(vbox, flaggedVirtual, down)));
}
@Test
void isLikelyVirtualInterfaceFlagsKnownAdaptersButNotRealNics() {
assertTrue(
GeneralUtils.isLikelyVirtualInterface(
"vEthernet", "Hyper-V Virtual Ethernet Adapter"));
assertTrue(GeneralUtils.isLikelyVirtualInterface("docker0", "docker0"));
assertTrue(
GeneralUtils.isLikelyVirtualInterface("eth0", "VMware Virtual Ethernet Adapter"));
assertTrue(GeneralUtils.isLikelyVirtualInterface("tun0", "WireGuard tunnel"));
assertFalse(GeneralUtils.isLikelyVirtualInterface("wlan0", "Intel(R) Wi-Fi 6 AX201"));
assertFalse(
GeneralUtils.isLikelyVirtualInterface(
"eth0", "Realtek PCIe GbE Family Controller"));
}
}
@@ -0,0 +1,422 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import stirling.software.common.configuration.InstallationPathConfig;
/**
* Branch-coverage gap tests for {@link GeneralUtils}. Targets size parsing/formatting, page-list
* and range handling, version comparison, URL validation, script/pipeline extraction validation,
* and the Ghostscript optimize failure paths not exercised by the existing GeneralUtils*Test files.
*/
class GeneralUtilsMoreTest {
@Nested
@DisplayName("convertSizeToBytes with explicit default unit")
class ConvertSizeWithDefaultUnitTests {
@Test
@DisplayName("invalid default unit throws IllegalArgumentException")
void invalidDefaultUnitThrows() {
assertThatThrownBy(() -> GeneralUtils.convertSizeToBytes("100", "ZB"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid default unit");
}
@ParameterizedTest(name = "value \"5\" with default unit {0} -> {1} bytes")
@CsvSource({"B, 5", "KB, 5120", "MB, 5242880", "GB, 5368709120", "TB, 5497558138880"})
@DisplayName("numeric value uses the supplied default unit")
void numericValueUsesDefaultUnit(String unit, long expected) {
assertEquals(expected, GeneralUtils.convertSizeToBytes("5", unit));
}
@Test
@DisplayName("lowercase default unit is normalized")
void lowercaseDefaultUnit() {
assertEquals(5L * 1024 * 1024, GeneralUtils.convertSizeToBytes("5", "mb"));
}
@Test
@DisplayName("explicit suffix overrides default unit")
void explicitSuffixOverridesDefault() {
// "2KB" should parse as KB even though default unit is GB.
assertEquals(2048L, GeneralUtils.convertSizeToBytes("2KB", "GB"));
}
@Test
@DisplayName("null default unit falls back to MB")
void nullDefaultUnitFallsBackToMb() {
assertEquals(3L * 1024 * 1024, GeneralUtils.convertSizeToBytes("3", null));
}
}
@Nested
@DisplayName("convertSizeToBytes suffix and edge parsing")
class ConvertSizeSuffixTests {
@Test
@DisplayName("comma decimal separator and embedded spaces are handled")
void commaAndSpaces() {
// "2,5 GB" -> "2.5GB" after normalization.
assertEquals(2684354560L, GeneralUtils.convertSizeToBytes("2,5 GB"));
}
@Test
@DisplayName("bare B suffix parses as bytes")
void bareBytes() {
assertEquals(42L, GeneralUtils.convertSizeToBytes("42B"));
}
@Test
@DisplayName("non-numeric body returns null")
void nonNumericReturnsNull() {
assertNull(GeneralUtils.convertSizeToBytes("abcMB"));
}
@Test
@DisplayName("negative value returns null")
void negativeReturnsNull() {
assertNull(GeneralUtils.convertSizeToBytes("-1KB"));
}
@Test
@DisplayName("zero is a valid size")
void zeroIsValid() {
assertEquals(0L, GeneralUtils.convertSizeToBytes("0MB"));
}
}
@Nested
@DisplayName("formatBytes boundaries")
class FormatBytesTests {
@Test
@DisplayName("negative bytes report invalid size")
void negativeInvalid() {
assertEquals("Invalid size", GeneralUtils.formatBytes(-1));
}
@Test
@DisplayName("terabyte range uses TB suffix")
void terabyteRange() {
long oneTb = 1024L * 1024L * 1024L * 1024L;
assertEquals("1.00 TB", GeneralUtils.formatBytes(oneTb));
}
@Test
@DisplayName("upper KB boundary just below a megabyte")
void kbBoundary() {
assertThat(GeneralUtils.formatBytes(1024L * 1024L - 1)).endsWith("KB");
}
}
@Nested
@DisplayName("parsePageList String overload")
class ParsePageListStringTests {
@Test
@DisplayName("null pages defaults to first page")
void nullDefaultsToFirst() {
// Cast disambiguates the String vs String[] overloads for a null literal.
assertEquals(List.of(1), GeneralUtils.parsePageList((String) null, 5, true));
}
@Test
@DisplayName("comma-separated list expands across tokens")
void commaSeparated() {
assertEquals(List.of(1, 3, 5), GeneralUtils.parsePageList("1,3,5", 5, true));
}
@Test
@DisplayName("'all' keyword via String overload returns every page")
void allKeyword() {
assertEquals(List.of(1, 2, 3), GeneralUtils.parsePageList("all", 3, true));
}
@Test
@DisplayName("two-argument overload defaults to zero-based output")
void twoArgOverloadZeroBased() {
assertEquals(List.of(0, 1, 2), GeneralUtils.parsePageList(new String[] {"1-3"}, 5));
}
@Test
@DisplayName("large in-range request stays within the max-size guard")
void largeRequestWithinGuard() {
// Pages are clamped to [1, total], so a wide range never trips the maxSize guard.
List<Integer> result = GeneralUtils.parsePageList(new String[] {"1-500"}, 500, true);
assertEquals(500, result.size());
}
}
@Nested
@DisplayName("range and single-page handling")
class RangeHandlingTests {
@Test
@DisplayName("open-ended range extends to the last page")
void openEndedRange() {
assertEquals(
List.of(3, 4, 5), GeneralUtils.parsePageList(new String[] {"3-"}, 5, true));
}
@Test
@DisplayName("invalid range bounds are skipped, valid tokens remain")
void invalidRangeSkipped() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"x-y", "2"}, 5, true);
assertEquals(List.of(2), result);
}
@Test
@DisplayName("out-of-range single page is dropped")
void outOfRangeSinglePage() {
assertTrue(GeneralUtils.parsePageList(new String[] {"99"}, 5, true).isEmpty());
}
@Test
@DisplayName("non-numeric single page is dropped")
void nonNumericSinglePage() {
assertTrue(GeneralUtils.parsePageList(new String[] {"abc"}, 5, true).isEmpty());
}
@Test
@DisplayName("range partially outside the document keeps in-bounds pages")
void rangePartlyOutOfBounds() {
assertEquals(List.of(4, 5), GeneralUtils.parsePageList(new String[] {"4-99"}, 5, true));
}
}
@Nested
@DisplayName("isVersionHigher")
class VersionTests {
@ParameterizedTest(name = "{0} > {1} == {2}")
@CsvSource({
"2.0.0, 1.9.9, true",
"1.0.0, 1.0.0, false",
"1.0, 1.0.1, false",
"1.0.1, 1.0, true",
"1.2, 1.10, false"
})
@DisplayName("compares version components numerically")
void comparesComponents(String a, String b, boolean expected) {
assertEquals(expected, GeneralUtils.isVersionHigher(a, b));
}
@Test
@DisplayName("null arguments yield false")
void nullArgs() {
assertFalse(GeneralUtils.isVersionHigher(null, "1.0"));
assertFalse(GeneralUtils.isVersionHigher("1.0", null));
}
@Test
@DisplayName("non-numeric component throws NumberFormatException")
void nonNumericComponentThrows() {
assertThrows(
NumberFormatException.class, () -> GeneralUtils.isVersionHigher("1.x", "1.0"));
}
}
@Nested
@DisplayName("isValidURL")
class ValidUrlTests {
@ParameterizedTest
@ValueSource(strings = {"https://example.com", "http://example.com/path?q=1"})
@DisplayName("well-formed external URLs are valid")
void validUrls(String url) {
assertTrue(GeneralUtils.isValidURL(url));
}
@ParameterizedTest
@ValueSource(strings = {"htp:/bad", "not a url", "://missing-scheme"})
@DisplayName("malformed URLs are rejected")
void invalidUrls(String url) {
assertFalse(GeneralUtils.isValidURL(url));
}
}
@Nested
@DisplayName("isValidUUID")
class UuidTests {
@Test
@DisplayName("null is not a valid UUID")
void nullUuid() {
assertFalse(GeneralUtils.isValidUUID(null));
}
@Test
@DisplayName("well-formed UUID is accepted")
void validUuid() {
assertTrue(GeneralUtils.isValidUUID("123e4567-e89b-12d3-a456-426614174000"));
}
@Test
@DisplayName("garbage string is rejected")
void garbageUuid() {
assertFalse(GeneralUtils.isValidUUID("xyz"));
}
}
@Nested
@DisplayName("createDir failure path")
class CreateDirFailureTests {
@Test
@DisplayName("returns false when directory creation throws IOException")
void createDirIoFailure(@TempDir Path tempDir) throws IOException {
// A regular file at the target path makes createDirectories fail.
Path asFile = tempDir.resolve("not-a-dir");
Files.writeString(asFile, "blocker");
Path child = asFile.resolve("child");
assertFalse(GeneralUtils.createDir(child.toString()));
}
}
@Nested
@DisplayName("extractScript validation")
class ExtractScriptTests {
@Test
@DisplayName("null or blank name is rejected")
void nullOrBlank() {
assertThrows(IllegalArgumentException.class, () -> GeneralUtils.extractScript(null));
assertThrows(IllegalArgumentException.class, () -> GeneralUtils.extractScript(" "));
}
@ParameterizedTest
@ValueSource(strings = {"../evil.py", "dir/script.py"})
@DisplayName("path-traversal characters are rejected")
void pathTraversalRejected(String name) {
assertThrows(IllegalArgumentException.class, () -> GeneralUtils.extractScript(name));
}
@Test
@DisplayName("name outside the allow-list is rejected")
void notInAllowList() {
assertThatThrownBy(() -> GeneralUtils.extractScript("random.py"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("png_to_webp.py");
}
}
@Nested
@DisplayName("extractPipeline invalid configuration")
class ExtractPipelineTests {
@Test
@DisplayName("missing classpath resource surfaces as IOException")
void missingResource(@TempDir Path tempDir) {
// Point the pipeline path at a temp dir; default pipeline JSONs are absent from
// the common module test classpath, so extraction fails with an IOException.
try (MockedStatic<InstallationPathConfig> mocked =
Mockito.mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getPipelinePath).thenReturn(tempDir.toString());
assertThrows(IOException.class, GeneralUtils::extractPipeline);
}
}
}
@Nested
@DisplayName("optimizePdfWithGhostscript failure handling")
class OptimizeGhostscriptTests {
@Test
@DisplayName("non-zero return code raises a Ghostscript exception")
void nonZeroReturnCode() throws Exception {
ProcessExecutor.ProcessExecutorResult result =
mock(ProcessExecutor.ProcessExecutorResult.class);
when(result.getMessages()).thenReturn("some ghostscript chatter");
when(result.getRc()).thenReturn(1);
ProcessExecutor executor = mock(ProcessExecutor.class);
// doReturn avoids referencing the checked-exception-declaring method during stubbing
Mockito.doReturn(result).when(executor).runCommandWithOutputHandling(Mockito.anyList());
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
mocked.when(
() ->
ProcessExecutor.getInstance(
ProcessExecutor.Processes.GHOSTSCRIPT))
.thenReturn(executor);
assertThrows(
IOException.class,
() -> GeneralUtils.optimizePdfWithGhostscript(new byte[] {1, 2, 3}));
}
}
@Test
@DisplayName("detected critical Ghostscript error is rethrown")
void criticalErrorDetected() throws Exception {
ProcessExecutor.ProcessExecutorResult result =
mock(ProcessExecutor.ProcessExecutorResult.class);
when(result.getMessages()).thenReturn("Page 1\ncould not draw this page");
ProcessExecutor executor = mock(ProcessExecutor.class);
Mockito.doReturn(result).when(executor).runCommandWithOutputHandling(Mockito.anyList());
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
mocked.when(
() ->
ProcessExecutor.getInstance(
ProcessExecutor.Processes.GHOSTSCRIPT))
.thenReturn(executor);
assertThatThrownBy(
() -> GeneralUtils.optimizePdfWithGhostscript(new byte[] {1, 2, 3}))
.isInstanceOf(ExceptionUtils.GhostscriptException.class);
}
}
}
@Nested
@DisplayName("selectBestSiteLocalIp edge cases")
class SelectBestIpTests {
@Test
@DisplayName("empty interface list returns null")
void emptyList() {
assertNull(GeneralUtils.selectBestSiteLocalIp(List.of()));
}
@Test
@DisplayName("non-private routable-style site-local IP still scores and is selected")
void otherRangeStillSelected() {
GeneralUtils.NetworkInterfaceInfo other =
new GeneralUtils.NetworkInterfaceInfo(
"eth0",
"Realtek PCIe GbE Family Controller",
2,
true,
false,
false,
false,
true,
List.of("172.16.5.5"));
assertEquals("172.16.5.5", GeneralUtils.selectBestSiteLocalIp(List.of(other)));
}
}
}
@@ -0,0 +1,195 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
/**
* Gap-filling tests for {@link PdfToCbrUtils#convertPdfToCbr} that drive the real PDFBox render
* loop with a tiny one-page PDF and mock the external {@code rar} process so the archive-creation
* branch is exercised without any external tool.
*/
class PdfToCbrUtilsMoreTest {
/** A one-page PDF containing a small embedded image so the renderer produces a PNG. */
private static byte[] onePageImagePdf() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(new PDRectangle(72, 72));
doc.addPage(page);
BufferedImage img = new BufferedImage(16, 16, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
g.setColor(Color.BLUE);
g.fillRect(0, 0, 16, 16);
g.dispose();
PDImageXObject pdImage = LosslessFactory.createFromImage(doc, img);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.drawImage(pdImage, 0, 0, 72, 72);
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private static MultipartFile pdfMultipart(byte[] bytes) {
return new MockMultipartFile("file", "comic.pdf", "application/pdf", bytes);
}
private static CustomPDFDocumentFactory factoryReturning(PDDocument document)
throws IOException {
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
when(factory.load(any(MultipartFile.class))).thenReturn(document);
return factory;
}
@Nested
@DisplayName("convertPdfToCbr - rar process branches")
class RarProcessTests {
@Test
@DisplayName("non-zero rar exit code surfaces as a processing exception")
void rarNonZeroExit() throws Exception {
PDDocument doc = Loader.loadPDF(onePageImagePdf());
CustomPDFDocumentFactory factory = factoryReturning(doc);
ProcessExecutorResult result = mock(ProcessExecutorResult.class);
when(result.getRc()).thenReturn(1);
ProcessExecutor executor = mock(ProcessExecutor.class);
Mockito.doReturn(result).when(executor).runCommandWithOutputHandling(anyList(), any());
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
mocked.when(
() ->
ProcessExecutor.getInstance(
ProcessExecutor.Processes.INSTALL_APP))
.thenReturn(executor);
assertThatThrownBy(
() ->
PdfToCbrUtils.convertPdfToCbr(
pdfMultipart(onePageImagePdf()), 72, factory))
.isInstanceOf(IOException.class);
}
doc.close();
}
@Test
@DisplayName("rc=0 but missing rar output file raises 'RAR file was not created'")
void rarFileNotCreated() throws Exception {
PDDocument doc = Loader.loadPDF(onePageImagePdf());
CustomPDFDocumentFactory factory = factoryReturning(doc);
ProcessExecutorResult result = mock(ProcessExecutorResult.class);
when(result.getRc()).thenReturn(0);
ProcessExecutor executor = mock(ProcessExecutor.class);
// No real rar runs, so the expected output.cbr is never produced.
Mockito.doReturn(result).when(executor).runCommandWithOutputHandling(anyList(), any());
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
mocked.when(
() ->
ProcessExecutor.getInstance(
ProcessExecutor.Processes.INSTALL_APP))
.thenReturn(executor);
assertThatThrownBy(
() ->
PdfToCbrUtils.convertPdfToCbr(
pdfMultipart(onePageImagePdf()), 72, factory))
.isInstanceOf(IOException.class)
.hasMessageContaining("RAR");
}
doc.close();
}
@Test
@DisplayName("an interrupted rar process is wrapped and the thread interrupt is restored")
void rarInterrupted() throws Exception {
PDDocument doc = Loader.loadPDF(onePageImagePdf());
CustomPDFDocumentFactory factory = factoryReturning(doc);
ProcessExecutor executor = mock(ProcessExecutor.class);
Mockito.doThrow(new InterruptedException("boom"))
.when(executor)
.runCommandWithOutputHandling(anyList(), any());
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
mocked.when(
() ->
ProcessExecutor.getInstance(
ProcessExecutor.Processes.INSTALL_APP))
.thenReturn(executor);
assertThatThrownBy(
() ->
PdfToCbrUtils.convertPdfToCbr(
pdfMultipart(onePageImagePdf()), 72, factory))
.isInstanceOf(Exception.class);
} finally {
// Clear the interrupt flag set by the handler so it doesn't leak into later tests.
Thread.interrupted();
doc.close();
}
}
}
@Nested
@DisplayName("convertPdfToCbr - document validation")
class DocumentValidationTests {
@Test
@DisplayName("a zero-page document raises a no-pages exception before rendering")
void zeroPageDocument() throws Exception {
try (PDDocument empty = new PDDocument()) {
CustomPDFDocumentFactory factory = factoryReturning(empty);
assertThatThrownBy(
() ->
PdfToCbrUtils.convertPdfToCbr(
pdfMultipart(onePageImagePdf()), 72, factory))
.isInstanceOf(Exception.class);
}
}
}
@Nested
@DisplayName("isPdfFile")
class IsPdfFileTests {
@Test
@DisplayName("a .cbr file is not a PDF")
void cbrIsNotPdf() {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("comic.cbr");
assertThat(PdfToCbrUtils.isPdfFile(file)).isFalse();
}
}
}
@@ -0,0 +1,316 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.rendering.ImageType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
/**
* Further gap-filling tests for {@link PdfUtils}, complementing {@code PdfUtilsTest} and {@code
* PdfUtilsGapTest}: the form-XObject recursion in image discovery, the found-text branch, the
* ApplicationProperties-present DPI lookups, the rotated/duplicate page-size paths, and the
* multi-frame TIFF input path of imageToPdf.
*/
class PdfUtilsMoreTest {
// ---- helpers ------------------------------------------------------------
/** Builds a PDF whose pages each show the given text phrase. */
private static PDDocument docWithText(String... pageTexts) throws IOException {
PDDocument doc = new PDDocument();
for (String text : pageTexts) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(100, 700);
cs.showText(text);
cs.endText();
}
}
return doc;
}
/** A small one-page PDF serialized to bytes. */
private static byte[] simplePdfBytes() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
/** Builds an ApplicationProperties whose system reports the given max DPI. */
private static ApplicationProperties propsWithMaxDpi(int dpi) {
ApplicationProperties props = new ApplicationProperties();
props.getSystem().setMaxDPI(dpi);
return props;
}
/** Encodes a multi-frame TIFF (two solid-colour frames) to bytes. */
private static byte[] multiFrameTiff() throws IOException {
ImageWriter writer = ImageIO.getImageWritersByFormatName("tiff").next();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) {
writer.setOutput(ios);
ImageWriteParam param = writer.getDefaultWriteParam();
writer.prepareWriteSequence(null);
for (Color c : new Color[] {Color.RED, Color.BLUE}) {
BufferedImage img = new BufferedImage(16, 16, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
g.setColor(c);
g.fillRect(0, 0, 16, 16);
g.dispose();
writer.writeToSequence(new IIOImage(img, null, null), param);
}
writer.endWriteSequence();
}
writer.dispose();
return baos.toByteArray();
}
// ---- getAllImages recursion --------------------------------------------
@Nested
@DisplayName("getAllImages with form XObjects")
class GetAllImagesForm {
@Test
@DisplayName("images nested inside a form XObject are discovered recursively")
void recursesIntoFormXObject() throws IOException {
try (PDDocument doc = new PDDocument()) {
// Build a form XObject that itself holds an image in its resources.
PDFormXObject form = new PDFormXObject(doc);
form.setResources(new PDResources());
BufferedImage bi = new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB);
PDImageXObject nested = LosslessFactory.createFromImage(doc, bi);
form.getResources().add(nested);
PDResources pageResources = new PDResources();
pageResources.add(form);
assertThat(PdfUtils.getAllImages(pageResources)).hasSize(1);
}
}
}
// ---- hasText found branch ----------------------------------------------
@Nested
@DisplayName("hasText found branch")
class HasTextFound {
@Test
@DisplayName("returns true when the phrase is present on a searched page")
void findsPhrase() throws IOException {
try (PDDocument doc = docWithText("NeedleInHaystack")) {
assertThat(PdfUtils.hasText(doc, "all", "NeedleInHaystack")).isTrue();
}
}
@Test
@DisplayName("returns true when the phrase is on the requested page only")
void findsPhraseOnSecondPage() throws IOException {
try (PDDocument doc = docWithText("first", "SecondMarker")) {
assertThat(PdfUtils.hasText(doc, "2", "SecondMarker")).isTrue();
}
}
}
// ---- convertFromPdf with ApplicationProperties present ------------------
@Nested
@DisplayName("convertFromPdf honouring configured max DPI")
class ConvertFromPdfWithProps {
@Test
@DisplayName("DPI under the configured limit renders; properties branch is taken")
void underConfiguredLimitRenders() throws Exception {
byte[] bytes = simplePdfBytes();
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
PDDocument doc = new PDDocument();
doc.addPage(new PDPage(new PDRectangle(20f, 20f)));
when(factory.load(bytes)).thenReturn(doc);
try (MockedStatic<ApplicationContextProvider> ctx =
Mockito.mockStatic(ApplicationContextProvider.class)) {
ctx.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
.thenReturn(propsWithMaxDpi(200));
byte[] out =
PdfUtils.convertFromPdf(
factory, bytes, "png", ImageType.RGB, true, 72, "doc", true);
assertThat(out).isNotEmpty();
}
}
@Test
@DisplayName("DPI above the configured limit throws using the configured maximum")
void aboveConfiguredLimitThrows() {
byte[] bytes = new byte[] {1, 2, 3};
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
try (MockedStatic<ApplicationContextProvider> ctx =
Mockito.mockStatic(ApplicationContextProvider.class)) {
ctx.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
.thenReturn(propsWithMaxDpi(100));
// 150 exceeds the configured limit of 100, so the limit check fires before loading.
org.junit.jupiter.api.Assertions.assertThrows(
IllegalArgumentException.class,
() ->
PdfUtils.convertFromPdf(
factory,
bytes,
"png",
ImageType.RGB,
true,
150,
"doc",
true));
}
}
@Test
@DisplayName("combined-image mode reuses the cached size for duplicate pages")
void combinedImageReusesDuplicatePageSize() throws Exception {
byte[] bytes = simplePdfBytes();
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
PDDocument doc = new PDDocument();
// Two identically-sized pages: the second hits the size cache.
doc.addPage(new PDPage(new PDRectangle(20f, 30f)));
doc.addPage(new PDPage(new PDRectangle(20f, 30f)));
when(factory.load(bytes)).thenReturn(doc);
byte[] out =
PdfUtils.convertFromPdf(
factory, bytes, "png", ImageType.RGB, true, 36, "doc", true);
assertThat(out).isNotEmpty();
}
@Test
@DisplayName("combined-image mode swaps dimensions for a rotated page")
void combinedImageRotatedPage() throws Exception {
byte[] bytes = simplePdfBytes();
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
PDDocument doc = new PDDocument();
PDPage rotated = new PDPage(new PDRectangle(20f, 30f));
rotated.setRotation(90);
doc.addPage(rotated);
when(factory.load(bytes)).thenReturn(doc);
byte[] out =
PdfUtils.convertFromPdf(
factory, bytes, "png", ImageType.RGB, true, 36, "doc", true);
assertThat(out).isNotEmpty();
}
}
// ---- convertPdfToPdfImage with ApplicationProperties present ------------
@Nested
@DisplayName("convertPdfToPdfImage honouring configured DPI")
class ConvertPdfToPdfImageWithProps {
@Test
@DisplayName("renders using the configured max DPI when properties are present")
void usesConfiguredDpi() throws IOException {
try (MockedStatic<ApplicationContextProvider> ctx =
Mockito.mockStatic(ApplicationContextProvider.class)) {
ctx.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
.thenReturn(propsWithMaxDpi(72));
try (PDDocument source = new PDDocument()) {
source.addPage(new PDPage(new PDRectangle(12f, 18f)));
try (PDDocument result = PdfUtils.convertPdfToPdfImage(source)) {
assertThat(result.getNumberOfPages()).isEqualTo(1);
}
}
}
}
}
// ---- imageToPdf with a multi-frame TIFF --------------------------------
@Nested
@DisplayName("imageToPdf with TIFF input")
class ImageToPdfTiff {
@Test
@DisplayName("a multi-frame TIFF produces one page per frame")
void multiFrameTiffBecomesMultiplePages() throws IOException {
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
when(factory.createNewDocument()).thenReturn(new PDDocument());
MockMultipartFile tiff =
new MockMultipartFile("file", "scan.tiff", "image/tiff", multiFrameTiff());
byte[] pdfOut =
PdfUtils.imageToPdf(
new MultipartFile[] {tiff}, "fillPage", false, "color", factory);
assertThat(pdfOut).isNotEmpty();
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(pdfOut)) {
assertThat(doc.getNumberOfPages()).isEqualTo(2);
}
}
@Test
@DisplayName("a .tif extension is also handled by the TIFF reader path")
void tifExtensionHandled() throws IOException {
CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class);
when(factory.createNewDocument()).thenReturn(new PDDocument());
MockMultipartFile tif =
new MockMultipartFile(
"file",
"scan.tif",
MediaType.APPLICATION_OCTET_STREAM_VALUE,
multiFrameTiff());
byte[] pdfOut =
PdfUtils.imageToPdf(
new MultipartFile[] {tif}, "fillPage", false, "color", factory);
try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(pdfOut)) {
assertThat(doc.getNumberOfPages()).isEqualTo(2);
}
}
}
}
@@ -0,0 +1,184 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.MockedConstruction;
import org.mockito.Mockito;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
/**
* Tests that drive {@link ProcessExecutor#runCommandWithOutputHandling} through its full
* output-handling logic by intercepting {@link ProcessBuilder} construction with {@link
* MockedConstruction}. The {@link Process} is mocked, so no real OS process is ever started.
*/
class ProcessExecutorMoreTest {
private ProcessExecutor qpdfExecutor() {
return ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF);
}
private ProcessExecutor ghostscriptExecutor() {
return ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT);
}
/** Configure a mocked Process with given streams, completion flag and exit code. */
private static Process mockedProcess(
String stdout, String stderr, boolean finished, int exitCode)
throws InterruptedException {
Process process = mock(Process.class);
when(process.getInputStream())
.thenReturn(new ByteArrayInputStream(stdout.getBytes(StandardCharsets.UTF_8)));
when(process.getErrorStream())
.thenReturn(new ByteArrayInputStream(stderr.getBytes(StandardCharsets.UTF_8)));
when(process.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(finished);
when(process.exitValue()).thenReturn(exitCode);
when(process.descendants()).thenReturn(Stream.empty());
return process;
}
/** Stub every constructed ProcessBuilder so start() returns the supplied process. */
private MockedConstruction<ProcessBuilder> stubProcessBuilder(Process process) {
return Mockito.mockConstruction(
ProcessBuilder.class,
(mockBuilder, context) -> {
when(mockBuilder.start()).thenReturn(process);
when(mockBuilder.directory(any())).thenReturn(mockBuilder);
});
}
@Nested
@DisplayName("runCommandWithOutputHandling - exit code handling")
class ExitCodeTests {
@Test
@DisplayName("a successful command (exit 0) returns rc=0 and captured output")
void successReturnsZero() throws Exception {
Process process = mockedProcess("hello output", "", true, 0);
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
ProcessExecutorResult result =
qpdfExecutor().runCommandWithOutputHandling(List.of("qpdf", "--version"));
assertThat(result.getRc()).isEqualTo(0);
assertThat(result.getMessages()).contains("hello output");
}
}
@Test
@DisplayName("a non-zero exit code with error output throws an IOException")
void nonZeroExitThrows() throws Exception {
Process process = mockedProcess("", "fatal: boom", true, 2);
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
assertThatThrownBy(
() ->
ghostscriptExecutor()
.runCommandWithOutputHandling(
List.of("gs", "-bad")))
.isInstanceOf(IOException.class)
.hasMessageContaining("exit code 2");
}
}
@Test
@DisplayName("a non-zero exit code without error output still throws with the log tail")
void nonZeroExitNoStderrThrows() throws Exception {
Process process = mockedProcess("some stdout only", "", true, 5);
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
assertThatThrownBy(
() ->
ghostscriptExecutor()
.runCommandWithOutputHandling(List.of("gs", "x")))
.isInstanceOf(IOException.class)
.hasMessageContaining("exit code 5");
}
}
}
@Nested
@DisplayName("runCommandWithOutputHandling - qpdf special-casing")
class QpdfTests {
@Test
@DisplayName("qpdf exit code 3 is treated as success-with-warnings, not a failure")
void qpdfExitThreeIsWarning() throws Exception {
Process process = mockedProcess("", "WARNING: minor issue", true, 3);
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
ProcessExecutorResult result =
qpdfExecutor()
.runCommandWithOutputHandling(List.of("qpdf", "--check", "in.pdf"));
assertThat(result.getRc()).isEqualTo(3);
}
}
@Test
@DisplayName("qpdf exit code 2 is still a hard failure")
void qpdfExitTwoFails() throws Exception {
Process process = mockedProcess("", "ERROR: broken", true, 2);
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
assertThatThrownBy(
() ->
qpdfExecutor()
.runCommandWithOutputHandling(
List.of("qpdf", "in.pdf")))
.isInstanceOf(IOException.class);
}
}
}
@Nested
@DisplayName("runCommandWithOutputHandling - timeout")
class TimeoutTests {
@Test
@DisplayName("a process that never finishes is destroyed and an IOException is thrown")
void timeoutThrows() throws Exception {
Process process = mockedProcess("", "", false, 0);
try (MockedConstruction<ProcessBuilder> ignored = stubProcessBuilder(process)) {
assertThatThrownBy(
() ->
qpdfExecutor()
.runCommandWithOutputHandling(
List.of("qpdf", "slow")))
.isInstanceOf(IOException.class)
.hasMessageContaining("timeout");
Mockito.verify(process).destroyForcibly();
}
}
}
@Nested
@DisplayName("runCommandWithOutputHandling - working directory overload")
class WorkingDirectoryTests {
@Test
@DisplayName("the working-directory overload runs the command and applies the directory")
void withWorkingDirectory() throws Exception {
Process process = mockedProcess("ok", "", true, 0);
try (MockedConstruction<ProcessBuilder> construction = stubProcessBuilder(process)) {
ProcessExecutorResult result =
qpdfExecutor()
.runCommandWithOutputHandling(
List.of("qpdf", "--version"),
new java.io.File(System.getProperty("java.io.tmpdir")));
assertThat(result.getRc()).isEqualTo(0);
// directory(...) must have been applied to the single constructed builder.
ProcessBuilder built = construction.constructed().get(0);
Mockito.verify(built).directory(any(java.io.File.class));
}
}
}
}
@@ -0,0 +1,354 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Gap-coverage tests for {@link RegexPatternUtils}. The existing RegexPatternUtilsTest covers
* caching mechanics; this file exercises the many lazily-built named accessor patterns, the static
* regex string getters, flag-aware cache operations, and the invalid-regex compile path.
*/
class RegexPatternUtilsMoreTest {
private final RegexPatternUtils utils = RegexPatternUtils.getInstance();
@Nested
@DisplayName("static regex string getters")
class StaticRegexTests {
@Test
@DisplayName("whitespace and extension regex strings are returned")
void staticStrings() {
assertEquals("\\s++", RegexPatternUtils.getWhitespaceRegex());
assertEquals("\\.(?:[^.]*+)?$", RegexPatternUtils.getExtensionRegex());
}
@Test
@DisplayName("supported new field types contains the documented set")
void supportedFieldTypes() {
Set<String> types = utils.getSupportedNewFieldTypes();
assertThat(types)
.contains(
"text",
"checkbox",
"combobox",
"listbox",
"radio",
"button",
"signature");
}
}
@Nested
@DisplayName("flag-aware cache operations")
class FlagCacheTests {
@Test
@DisplayName("removeFromCache with flags removes the flagged entry only")
void removeWithFlags() {
String regex = "moreflagcache\\d+";
utils.getPattern(regex, Pattern.CASE_INSENSITIVE);
assertTrue(utils.isCached(regex, Pattern.CASE_INSENSITIVE));
assertTrue(utils.removeFromCache(regex, Pattern.CASE_INSENSITIVE));
assertFalse(utils.isCached(regex, Pattern.CASE_INSENSITIVE));
// Removing again returns false.
assertFalse(utils.removeFromCache(regex, Pattern.CASE_INSENSITIVE));
}
@Test
@DisplayName("isCached with flags is false for null regex")
void isCachedNullWithFlags() {
assertFalse(utils.isCached(null, Pattern.CASE_INSENSITIVE));
}
@Test
@DisplayName("removeFromCache with flags is false for null regex")
void removeNullWithFlags() {
assertFalse(utils.removeFromCache(null, Pattern.CASE_INSENSITIVE));
}
}
@Nested
@DisplayName("invalid regex compilation")
class InvalidRegexTests {
@Test
@DisplayName("an invalid pattern propagates PatternSyntaxException")
void invalidPattern() {
assertThrows(PatternSyntaxException.class, () -> utils.getPattern("[unclosed"));
}
}
@Nested
@DisplayName("path and filename patterns")
class PathFilenameTests {
@Test
void driveLetterPattern() {
assertTrue(utils.getDriveLetterPattern().matcher("C:\\Users\\x").find());
}
@Test
void leadingSlashesPattern() {
assertTrue(utils.getLeadingSlashesPattern().matcher("//leading").find());
}
@Test
void backslashPattern() {
assertTrue(utils.getBackslashPattern().matcher("a\\b").find());
}
@Test
void filenameSafePattern() {
assertTrue(utils.getFilenameSafePattern().matcher("a!b").find());
}
@Test
void nonAlnumUnderscorePattern() {
assertTrue(utils.getNonAlnumUnderscorePattern().matcher("a-b").find());
assertFalse(utils.getNonAlnumUnderscorePattern().matcher("a_b").find());
}
@Test
void underscoreCollapsePatterns() {
assertTrue(utils.getMultipleUnderscoresPattern().matcher("a__b").find());
assertTrue(utils.getLeadingUnderscoresPattern().matcher("__a").find());
assertTrue(utils.getTrailingUnderscoresPattern().matcher("a__").find());
}
@Test
void uploadDownloadPathPattern() {
assertTrue(utils.getUploadDownloadPathPattern().matcher("/api/UPLOAD/file").matches());
}
}
@Nested
@DisplayName("whitespace, newline and word patterns")
class WhitespaceNewlineTests {
@Test
void whitespaceAndWordSplit() {
assertEquals(2, utils.getWordSplitPattern().split("a b").length);
assertTrue(utils.getWhitespacePattern().matcher("a b").find());
}
@Test
void punctuationPattern() {
assertTrue(utils.getPunctuationPattern().matcher("a!b").find());
}
@Test
void newlineVariants() {
assertTrue(utils.getNewlinesPattern().matcher("a\r\nb").find());
assertTrue(utils.getNewlineSplitPattern().matcher("a\nb").find());
assertTrue(utils.getCarriageReturnPattern().matcher("a\rb").find());
assertTrue(utils.getNewlineCharsPattern().matcher("a\nb").find());
assertTrue(utils.getMultiFormatNewlinePattern().matcher("a\r\nb").find());
assertTrue(utils.getEncodedPayloadNewlinePattern().matcher("a\nb").find());
assertTrue(utils.getLineSeparatorPattern().matcher("a\nb").find());
}
@Test
void escapedNewlinePattern() {
assertTrue(utils.getEscapedNewlinePattern().matcher("line\\nbreak").find());
}
}
@Nested
@DisplayName("sanitization and field-name patterns")
class SanitizationTests {
@Test
void inputSanitizePattern() {
assertTrue(utils.getInputSanitizePattern().matcher("a@b").find());
}
@Test
void formFieldBracketPattern() {
assertEquals(
"field", utils.getFormFieldBracketPattern().matcher("field[0]").replaceAll(""));
}
@Test
void underscoreHyphenPattern() {
assertTrue(utils.getUnderscoreHyphenPattern().matcher("a-_b").find());
}
@Test
void camelCaseBoundaryPattern() {
assertEquals(
"first Name",
utils.getCamelCaseBoundaryPattern().matcher("firstName").replaceAll(" "));
}
@Test
void angleBracketsAndQuotes() {
assertTrue(utils.getAngleBracketsPattern().matcher("a<b>c").find());
assertTrue(utils.getQuotesRemovalPattern().matcher("\"q\"").find());
}
@Test
void plusAndPipe() {
assertTrue(utils.getPlusSignPattern().matcher("a+b").find());
assertEquals(2, utils.getPipeDelimiterPattern().split("a|b").length);
}
@Test
void usernameValidationPattern() {
assertTrue(utils.getUsernameValidationPattern().matcher("john_doe1").matches());
assertFalse(utils.getUsernameValidationPattern().matcher("a--b").matches());
}
@Test
void genericAndSimpleFieldPatterns() {
assertTrue(utils.getGenericFieldNamePattern().matcher("Field 1").matches());
assertTrue(utils.getSimpleFormFieldPattern().matcher("t1").matches());
assertTrue(utils.getOptionalTNumericPattern().matcher("t 12").matches());
}
}
@Nested
@DisplayName("number and math patterns")
class NumberMathTests {
@Test
void numericExtractionAndDigitPatterns() {
assertTrue(utils.getNumericExtractionPattern().matcher("a1").find());
assertTrue(utils.getNonDigitDotPattern().matcher("1a").find());
assertTrue(utils.getDigitDotPattern().matcher("1.0").find());
assertTrue(utils.getContainsDigitsPattern().matcher("ab12cd").matches());
assertTrue(utils.getNumberRangePattern().matcher("250").matches());
}
@Test
void mathExpressionPatterns() {
assertTrue(utils.getMathExpressionPattern().matcher("2n+1").matches());
assertTrue(utils.getNumberBeforeNPattern().matcher("4n").find());
assertTrue(utils.getConsecutiveNPattern().matcher("annb").matches());
assertTrue(utils.getConsecutiveNReplacementPattern().matcher("nn").find());
}
}
@Nested
@DisplayName("url, email and html patterns")
class UrlEmailHtmlTests {
@Test
void httpAndLinkPatterns() {
assertTrue(utils.getHttpUrlPattern().matcher("https://x.com").matches());
assertTrue(utils.getUrlLinkPattern().matcher("see http://x.com/a").find());
assertTrue(utils.getEmailLinkPattern().matcher("a@b.com").find());
}
@Test
void emailValidationPattern() {
assertTrue(utils.getEmailValidationPattern().matcher("user@example.com").matches());
assertFalse(utils.getEmailValidationPattern().matcher("not-an-email").matches());
}
@Test
void scriptStyleAndCssPatterns() {
assertTrue(utils.getScriptTagPattern().matcher("<script>x()</script>").find());
assertTrue(utils.getStyleTagPattern().matcher("<style>a{}</style>").find());
assertTrue(utils.getFixedPositionCssPattern().matcher("position: fixed;").find());
assertTrue(utils.getAbsolutePositionCssPattern().matcher("position: absolute;").find());
}
@Test
void inlineCidAndImagePatterns() {
assertTrue(utils.getInlineCidImagePattern().matcher("<img src=\"cid:abc\">").find());
assertTrue(utils.getImageFilePattern().matcher("photo.JPG").matches());
}
}
@Nested
@DisplayName("size, temp-file and mime patterns")
class SizeTempMimeTests {
@Test
void sizeUnitPattern() {
assertTrue(utils.getSizeUnitPattern().matcher("MB").find());
}
@Test
void systemTempFilePatterns() {
assertTrue(utils.getSystemTempFile1Pattern().matcher("lu123abc.tmp").find());
assertTrue(utils.getSystemTempFile2Pattern().matcher("ocr_process42").find());
}
@Test
void whitespaceParensSplit() {
assertTrue(utils.getWhitespaceParenthesesSplitPattern().matcher("a (b)").find());
}
@Test
void mimeHeaderAndEncodedWord() {
assertTrue(utils.getMimeHeaderWhitespacePattern().matcher("a =?utf-8").find());
assertTrue(utils.getMimeEncodedWordPattern().matcher("=?utf-8?B?abc?=").find());
}
@Test
void fontNamePattern() {
assertTrue(utils.getFontNamePattern().matcher("ABCDEF+Arial").matches());
}
}
@Nested
@DisplayName("xml, attachment and api-doc patterns")
class XmlAttachmentApiTests {
@Test
void accessReadOnlyAndXmpPatterns() {
assertTrue(utils.getAccessReadOnlyPattern().matcher("access=\"readOnly\"").find());
assertTrue(utils.getPdfAidPartPattern().matcher("pdfaid:part=\"2\"").find());
assertTrue(
utils.getPdfAidConformancePattern().matcher("pdfaid:conformance=\"B\"").find());
}
@Test
void attachmentPatterns() {
assertTrue(utils.getAttachmentSectionPattern().matcher("Attachments (3)").find());
assertTrue(utils.getAttachmentFilenamePattern().matcher("@ file.txt").find());
}
@Test
void pageModeAndApiDocPatterns() {
assertTrue(utils.getPageModePattern().matcher("a/b").find());
assertTrue(utils.getApiDocOutputTypePattern().matcher("Output: PDF").find());
assertTrue(utils.getApiDocInputTypePattern().matcher("Input: PDF").find());
assertTrue(utils.getApiDocTypePattern().matcher("Type: WEB").find());
}
@Test
void fileExtensionValidationAndLeadingAsterisks() {
assertTrue(utils.getFileExtensionValidationPattern().matcher("pdf").matches());
assertFalse(utils.getFileExtensionValidationPattern().matcher("a").matches());
assertEquals(
"text",
utils.getLeadingAsterisksWhitespacePattern()
.matcher("** text")
.replaceFirst(""));
}
}
@Test
@DisplayName("every cached accessor returns a non-null pattern")
void accessorsNeverNull() {
assertNotNull(utils.getTrailingSlashesPattern());
assertNotNull(utils.getSafeFilenamePattern());
assertNotNull(utils.getWordSplitPattern());
}
}
@@ -0,0 +1,203 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.snakeyaml.engine.v2.api.LoadSettings;
class YamlHelperMoreTest {
private static final LoadSettings LOAD_SETTINGS =
LoadSettings.builder()
.setUseMarks(true)
.setMaxAliasesForCollections(Integer.MAX_VALUE)
.setAllowRecursiveKeys(true)
.setParseComments(true)
.build();
private YamlHelper helper(String yaml) {
return new YamlHelper(LOAD_SETTINGS, yaml);
}
@Nested
@DisplayName("updateValue value-type handling")
class UpdateValueTypes {
@Test
@DisplayName("updates an integer value with INT tag")
void integerValue() {
YamlHelper h = helper("server:\n port: 80\n");
assertThat(h.updateValue(List.of("server", "port"), 8080)).isTrue();
assertThat(h.getValueByExactKeyPath("server", "port")).isEqualTo("8080");
}
@Test
@DisplayName("updates a float value")
void floatValue() {
YamlHelper h = helper("scale:\n factor: 1.0\n");
assertThat(h.updateValue(List.of("scale", "factor"), 2.5f)).isTrue();
assertThat(String.valueOf(h.getValueByExactKeyPath("scale", "factor")))
.startsWith("2.5");
}
@Test
@DisplayName("updates a boolean value via string literal")
void booleanValue() {
YamlHelper h = helper("flags:\n on: false\n");
assertThat(h.updateValue(List.of("flags", "on"), "true")).isTrue();
assertThat(h.getValueByExactKeyPath("flags", "on")).isEqualTo("true");
}
@Test
@DisplayName("replaces a scalar with a Map value (MappingNode)")
void mapValue() {
YamlHelper h = helper("meta:\n data: placeholder\n");
Map<String, Object> map = new LinkedHashMap<>();
map.put("author", "alice");
map.put("year", 2024);
assertThat(h.updateValue(List.of("meta", "data"), map)).isTrue();
assertThat(h.getValueByExactKeyPath("meta", "data", "author")).isEqualTo("alice");
assertThat(h.getValueByExactKeyPath("meta", "data", "year")).isEqualTo("2024");
}
@Test
@DisplayName("replaces a scalar with a List value (SequenceNode)")
void listValue() {
YamlHelper h = helper("cfg:\n items: x\n");
assertThat(h.updateValue(List.of("cfg", "items"), List.of("a", "b", "c"))).isTrue();
Object value = h.getValueByExactKeyPath("cfg", "items");
assertThat(value).isInstanceOf(List.class);
List<?> list = (List<?>) value;
assertThat(list).hasSize(3);
assertThat(list.toString()).contains("a").contains("b").contains("c");
}
@Test
@DisplayName("list with mixed scalar element types is converted")
void mixedListValue() {
YamlHelper h = helper("cfg:\n vals: x\n");
assertThat(h.updateValue(List.of("cfg", "vals"), List.of("s", 1, 2.5, "true")))
.isTrue();
Object value = h.getValueByExactKeyPath("cfg", "vals");
assertThat((List<?>) value).hasSize(4);
}
@Test
@DisplayName("updates a previously null scalar")
void nullScalarBecomesValue() {
YamlHelper h = helper("opt:\n value:\n");
assertThat(h.updateValue(List.of("opt", "value"), "set")).isTrue();
assertThat(h.getValueByExactKeyPath("opt", "value")).isEqualTo("set");
}
@Test
@DisplayName("updates a null scalar to a boolean (BOOL tag promotion)")
void nullScalarBecomesBoolean() {
YamlHelper h = helper("opt:\n enabled:\n");
assertThat(h.updateValue(List.of("opt", "enabled"), Boolean.TRUE)).isTrue();
assertThat(h.getValueByExactKeyPath("opt", "enabled")).isEqualTo("true");
}
@Test
@DisplayName("returns false when intermediate key path is not a mapping")
void nonMappingPathReturnsFalse() {
YamlHelper h = helper("server:\n port: 80\n");
// 'port' is a scalar, so descending into it cannot update.
assertThat(h.updateValue(List.of("server", "port", "deeper"), "x")).isFalse();
}
}
@Nested
@DisplayName("updateValuesFromYaml")
class UpdateFromYaml {
@Test
@DisplayName("copies differing existing keys from source into target")
void copiesChangedValues() {
YamlHelper target = helper("server:\n port: 80\n host: localhost\n");
YamlHelper source = helper("server:\n port: 9090\n host: localhost\n");
boolean updated = target.updateValuesFromYaml(source, target);
assertThat(updated).isTrue();
assertThat(target.getValueByExactKeyPath("server", "port")).isEqualTo("9090");
}
@Test
@DisplayName("source keys absent from target are not added (no update)")
void unknownKeysIgnored() {
YamlHelper target = helper("server:\n port: 80\n");
YamlHelper source = helper("server:\n port: 80\n");
boolean updated = target.updateValuesFromYaml(source, target);
assertThat(updated).isFalse();
assertThat(target.getValueByExactKeyPath("server", "port")).isEqualTo("80");
}
}
@Nested
@DisplayName("save / saveOverride / node tracking")
class SaveAndNodes {
@Test
@DisplayName("save to the original path is a no-op write but returns the mapping")
void saveSamePathNoRewrite(@TempDir Path tempDir) throws IOException {
Path file = tempDir.resolve("orig.yaml");
Files.writeString(file, "a:\n b: 1\n");
YamlHelper h = new YamlHelper(file);
h.updateValue(List.of("a", "b"), 2);
// Same path: method must not rewrite the file but still return a MappingNode.
assertThat(h.save(file)).isNotNull();
}
@Test
@DisplayName("saveOverride writes to disk")
void saveOverrideWrites(@TempDir Path tempDir) throws IOException {
YamlHelper h = helper("a:\n b: 1\n");
h.updateValue(List.of("a", "b"), 42);
Path out = tempDir.resolve("out.yaml");
h.saveOverride(out);
assertThat(Files.readString(out)).contains("42");
}
@Test
@DisplayName("setNewNode then getUpdatedRootNode returns the set node")
void setAndGetNode() {
YamlHelper h = helper("a:\n b: 1\n");
var root = h.getUpdatedRootNode();
h.setNewNode(root);
assertThat(h.getUpdatedRootNode()).isSameAs(root);
}
}
@Nested
@DisplayName("static numeric type checks")
class NumericChecks {
@Test
@DisplayName("isShort / isByte accept Long and parsable strings")
void shortAndByte() {
assertThat(YamlHelper.isShort(5L)).isTrue();
assertThat(YamlHelper.isShort("100")).isTrue();
assertThat(YamlHelper.isShort("notNumeric")).isFalse();
assertThat(YamlHelper.isByte(1L)).isTrue();
assertThat(YamlHelper.isByte("7")).isTrue();
assertThat(YamlHelper.isByte("999999")).isFalse();
}
@Test
@DisplayName("isInteger rejects null and non-numeric, accepts boxed integers")
void integerEdges() {
assertThat(YamlHelper.isInteger(null)).isFalse();
assertThat(YamlHelper.isInteger((byte) 3)).isTrue();
assertThat(YamlHelper.isInteger((short) 9)).isTrue();
}
}
}
@@ -0,0 +1,256 @@
package stirling.software.common.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import stirling.software.common.model.ApplicationProperties;
/**
* Tests for {@link ZipExtractionUtils} that build real in-memory ZIP byte streams and exercise
* detection, flat extraction, nested-ZIP recursion, directory skipping and corrupt-input handling.
* No external process is launched.
*/
class ZipExtractionUtilsTest {
private TempFileManager tempFileManager;
private final List<TempFile> created = new ArrayList<>();
@TempDir Path tempDir;
@BeforeEach
void setUp() {
ApplicationProperties props = new ApplicationProperties();
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
props.getSystem().getTempFileManagement().setPrefix("test-zip-");
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
}
@AfterEach
void tearDown() {
for (TempFile tf : created) {
tf.close();
}
created.clear();
}
// ----- helpers -----------------------------------------------------------
/** Build a flat ZIP from name->bytes entries. */
private static byte[] buildZip(String[] names, byte[][] contents) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
for (int i = 0; i < names.length; i++) {
zos.putNextEntry(new ZipEntry(names[i]));
if (contents[i] != null) {
zos.write(contents[i]);
}
zos.closeEntry();
}
}
return baos.toByteArray();
}
private static byte[] bytes(String s) {
return s.getBytes(StandardCharsets.UTF_8);
}
private static Resource resource(byte[] data) {
return new ByteArrayResource(data);
}
private static String drain(Resource r) throws IOException {
try (InputStream is = r.getInputStream()) {
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
}
}
@Nested
@DisplayName("isZip")
class IsZipTests {
@Test
@DisplayName("real ZIP magic bytes are detected")
void detectsRealZip() throws IOException {
byte[] zip = buildZip(new String[] {"a.txt"}, new byte[][] {bytes("hi")});
assertThat(ZipExtractionUtils.isZip(resource(zip))).isTrue();
}
@Test
@DisplayName("non-ZIP content is rejected")
void rejectsNonZip() throws IOException {
assertThat(ZipExtractionUtils.isZip(resource(bytes("not a zip at all")))).isFalse();
}
@Test
@DisplayName("null resource is not a ZIP")
void nullResource() throws IOException {
assertThat(ZipExtractionUtils.isZip(null)).isFalse();
}
@Test
@DisplayName("content shorter than the magic prefix is not a ZIP")
void tooShort() throws IOException {
assertThat(ZipExtractionUtils.isZip(resource(new byte[] {0x50, 0x4B}))).isFalse();
}
@Test
@DisplayName(".cbz filename is explicitly excluded even with ZIP magic bytes")
void cbzExcluded() throws IOException {
byte[] zip = buildZip(new String[] {"page.png"}, new byte[][] {bytes("img")});
assertThat(ZipExtractionUtils.isZip(resource(zip), "comic.cbz")).isFalse();
}
@Test
@DisplayName(".cbz exclusion is case-insensitive")
void cbzExcludedUppercase() throws IOException {
byte[] zip = buildZip(new String[] {"page.png"}, new byte[][] {bytes("img")});
assertThat(ZipExtractionUtils.isZip(resource(zip), "COMIC.CBZ")).isFalse();
}
@Test
@DisplayName("a non-cbz filename does not suppress detection")
void nonCbzFilenameStillDetected() throws IOException {
byte[] zip = buildZip(new String[] {"a.txt"}, new byte[][] {bytes("x")});
assertThat(ZipExtractionUtils.isZip(resource(zip), "bundle.zip")).isTrue();
}
@Test
@DisplayName("first four bytes that differ from the magic are rejected")
void wrongMagicBytes() throws IOException {
byte[] data = {0x50, 0x4B, 0x05, 0x06, 0x00, 0x00};
assertThat(ZipExtractionUtils.isZip(resource(data))).isFalse();
}
}
@Nested
@DisplayName("extractZip")
class ExtractZipTests {
@Test
@DisplayName("flat ZIP extracts one resource per file entry with filenames preserved")
void flatExtraction() throws IOException {
byte[] zip =
buildZip(
new String[] {"first.txt", "second.txt"},
new byte[][] {bytes("one"), bytes("two")});
List<Resource> result = ZipExtractionUtils.extractZip(resource(zip), tempFileManager);
assertThat(result).hasSize(2);
assertThat(result)
.extracting(Resource::getFilename)
.containsExactlyInAnyOrder("first.txt", "second.txt");
assertThat(drain(result.get(0)) + drain(result.get(1))).contains("one").contains("two");
}
@Test
@DisplayName("directory entries are skipped")
void directoriesSkipped() throws IOException {
byte[] zip =
buildZip(
new String[] {"dir/", "dir/file.txt"},
new byte[][] {null, bytes("payload")});
List<Resource> result = ZipExtractionUtils.extractZip(resource(zip), tempFileManager);
assertThat(result).hasSize(1);
assertThat(result.get(0).getFilename()).isEqualTo("dir/file.txt");
}
@Test
@DisplayName("empty ZIP yields no resources")
void emptyZip() throws IOException {
byte[] zip = buildZip(new String[] {}, new byte[][] {});
List<Resource> result = ZipExtractionUtils.extractZip(resource(zip), tempFileManager);
assertThat(result).isEmpty();
}
@Test
@DisplayName("nested ZIP entries are recursively expanded")
void nestedExtraction() throws IOException {
byte[] inner =
buildZip(new String[] {"inner.txt"}, new byte[][] {bytes("nested-content")});
byte[] outer =
buildZip(
new String[] {"top.txt", "child.zip"},
new byte[][] {bytes("top-content"), inner});
List<Resource> result = ZipExtractionUtils.extractZip(resource(outer), tempFileManager);
// top.txt + the single file inside child.zip => 2 flat resources
assertThat(result).hasSize(2);
assertThat(result)
.extracting(Resource::getFilename)
.containsExactlyInAnyOrder("top.txt", "inner.txt");
}
@Test
@DisplayName("tempFileConsumer receives every created temp file")
void consumerInvoked() throws IOException {
byte[] zip =
buildZip(
new String[] {"a.txt", "b.txt"}, new byte[][] {bytes("a"), bytes("b")});
List<TempFile> seen = new ArrayList<>();
List<Resource> result =
ZipExtractionUtils.extractZip(
resource(zip),
tempFileManager,
tf -> {
seen.add(tf);
created.add(tf);
});
assertThat(result).hasSize(2);
assertThat(seen).hasSize(2);
}
@Test
@DisplayName("a .cbz entry inside the ZIP is kept as a single file, not recursed")
void cbzEntryNotRecursed() throws IOException {
byte[] innerZip = buildZip(new String[] {"page.png"}, new byte[][] {bytes("imgdata")});
byte[] outer = buildZip(new String[] {"book.cbz"}, new byte[][] {innerZip});
List<Resource> result = ZipExtractionUtils.extractZip(resource(outer), tempFileManager);
assertThat(result).hasSize(1);
assertThat(result.get(0).getFilename()).isEqualTo("book.cbz");
}
@Test
@DisplayName("a truncated ZIP entry stream surfaces as an IOException")
void corruptZip() throws IOException {
// Build a real ZIP with compressible content, then truncate it mid-stream so the
// deflate entry cannot be fully read and extraction fails.
byte[] valid =
buildZip(new String[] {"big.txt"}, new byte[][] {bytes("A".repeat(8192))});
byte[] truncated = new byte[valid.length / 2];
System.arraycopy(valid, 0, truncated, 0, truncated.length);
assertThatThrownBy(
() ->
ZipExtractionUtils.extractZip(
resource(truncated), tempFileManager))
.isInstanceOf(IOException.class);
}
}
}
@@ -0,0 +1,162 @@
package stirling.software.common.util.misc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.InputStreamResource;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.model.api.misc.HighContrastColorCombination;
import stirling.software.common.model.api.misc.ReplaceAndInvert;
/**
* Gap-filling tests for {@link CustomColorReplaceStrategy#replace()} that run the full restyle loop
* against real, tiny PDFs built in-memory with PDFBox. No external process is launched.
*/
class CustomColorReplaceStrategyMoreTest {
/**
* A one-page PDF that draws a line of text so the restyle loop has TextPositions to process.
*/
private static byte[] pdfWithText(String text) throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(72, 700);
cs.showText(text);
cs.endText();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private static byte[] emptyPagePdf() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private static MultipartFile pdf(byte[] bytes) {
return new MockMultipartFile("file", "input.pdf", "application/pdf", bytes);
}
private static int pageCount(InputStreamResource resource) throws IOException {
try (InputStream is = resource.getInputStream();
PDDocument doc = Loader.loadPDF(is.readAllBytes())) {
return doc.getNumberOfPages();
}
}
@Nested
@DisplayName("replace - custom colours")
class CustomColourTests {
@Test
@DisplayName("restyles text and overlays a background, returning a valid PDF")
void customColoursProduceValidPdf() throws Exception {
CustomColorReplaceStrategy strategy =
new CustomColorReplaceStrategy(
pdf(pdfWithText("Hello World")),
ReplaceAndInvert.CUSTOM_COLOR,
"#000000",
"#FFFFFF",
null);
InputStreamResource result = strategy.replace();
assertThat(result).isNotNull();
assertThat(pageCount(result)).isEqualTo(1);
}
@Test
@DisplayName("a page without any text still gets the background overlay")
void emptyPageStillProcessed() throws Exception {
CustomColorReplaceStrategy strategy =
new CustomColorReplaceStrategy(
pdf(emptyPagePdf()),
ReplaceAndInvert.CUSTOM_COLOR,
"#112233",
"#AABBCC",
null);
InputStreamResource result = strategy.replace();
assertThat(pageCount(result)).isEqualTo(1);
}
@Test
@DisplayName("text restyling runs through the font-encoding path without failing")
void fontEncodingPathExercised() throws Exception {
CustomColorReplaceStrategy strategy =
new CustomColorReplaceStrategy(
pdf(pdfWithText("Hi there 123")),
ReplaceAndInvert.CUSTOM_COLOR,
"#101010",
"#FFFFFF",
null);
InputStreamResource result = strategy.replace();
assertThat(pageCount(result)).isEqualTo(1);
}
}
@Nested
@DisplayName("replace - high contrast colours")
class HighContrastTests {
@Test
@DisplayName("high-contrast mode resolves colours from the combination and produces a PDF")
void highContrastProducesValidPdf() throws Exception {
CustomColorReplaceStrategy strategy =
new CustomColorReplaceStrategy(
pdf(pdfWithText("Contrast")),
ReplaceAndInvert.HIGH_CONTRAST_COLOR,
null,
null,
HighContrastColorCombination.WHITE_TEXT_ON_BLACK);
InputStreamResource result = strategy.replace();
assertThat(pageCount(result)).isEqualTo(1);
}
}
@Nested
@DisplayName("replace - invalid input")
class InvalidInputTests {
@Test
@DisplayName("a non-PDF payload causes replace() to throw")
void nonPdfThrows() {
CustomColorReplaceStrategy strategy =
new CustomColorReplaceStrategy(
pdf("not a pdf".getBytes()),
ReplaceAndInvert.CUSTOM_COLOR,
"000000",
"FFFFFF",
null);
assertThatThrownBy(strategy::replace).isInstanceOf(IOException.class);
}
}
}
+6
View File
@@ -330,6 +330,12 @@ tasks.register('cleanFrontendAssets', Delete) {
group = 'frontend'
description = 'Remove previously generated frontend assets from static resources'
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
// Prerendered per-route SPA pages (e.g. compress.html) carry per-tool OG tags and are
// copied from the frontend build. Remove stale ones so renamed/removed tools don't linger.
// api-landing.html and mobile-upload.html are real backend source files, not generated artifacts.
delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html'])
// Nested prerendered route pages (e.g. settings/people.html)
delete new File(resourcesStaticDir, 'settings')
}
tasks.register('copyApiLandingPage', Copy) {
@@ -0,0 +1,80 @@
package stirling.software.SPDF.config;
import java.util.List;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.PdfMetricsService;
@Component
@Slf4j
@RequiredArgsConstructor
public class PdfMetricsInterceptor implements HandlerInterceptor {
private final PdfMetricsService pdfMetricsService;
@Override
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
try {
if (!pdfMetricsService.isEnabled()) {
return;
}
if (!"POST".equalsIgnoreCase(request.getMethod()) || response.getStatus() >= 400) {
return;
}
String path = request.getServletPath();
if (path == null || path.isBlank()) {
path = request.getRequestURI();
}
if (path == null || !path.contains("/api/v1/")) {
return;
}
if (!(request instanceof MultipartHttpServletRequest multipart)) {
return;
}
if (isFromEditor(request)) {
return;
}
int fileCount = 0;
for (List<MultipartFile> bucket : multipart.getMultiFileMap().values()) {
fileCount += bucket.size();
}
if (fileCount == 0) {
return;
}
pdfMetricsService.recordOperation(fileCount);
} catch (Exception e) {
log.debug("Failed to record PDF metrics", e);
}
}
// Editor traffic carries X-Browser-Id, or (if a proxy strips it) a logged-in user's JWT.
// JWTs start "eyJ" and have two dots; API keys do not, so they still count as API.
private boolean isFromEditor(HttpServletRequest request) {
String browserId = request.getHeader("X-Browser-Id");
if (browserId != null && !browserId.isBlank()) {
return true;
}
String auth = request.getHeader("Authorization");
if (auth == null || !auth.regionMatches(true, 0, "Bearer ", 0, 7)) {
return false;
}
String token = auth.substring(7).trim();
return token.startsWith("eyJ") && token.chars().filter(c -> c == '.').count() == 2;
}
}
@@ -24,6 +24,7 @@ import stirling.software.common.model.ApplicationProperties;
public class WebMvcConfig implements WebMvcConfigurer {
private final EndpointInterceptor endpointInterceptor;
private final PdfMetricsInterceptor pdfMetricsInterceptor;
private final ApplicationProperties applicationProperties;
private static final Logger logger = LoggerFactory.getLogger(WebMvcConfig.class);
@@ -35,6 +36,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(endpointInterceptor);
registry.addInterceptor(pdfMetricsInterceptor);
}
@Override
@@ -42,6 +42,8 @@ public class ReactRoutingController {
private boolean loggedMissingIndex = false;
private String cachedSaasLandingHtml;
private boolean saasLandingExists = false;
private String cachedMobileUploadHtml;
private boolean mobileUploadHtmlExists = false;
@PostConstruct
public void init() {
@@ -64,6 +66,12 @@ public class ReactRoutingController {
}
}
// Desktop (Tauri) serves the SPA from its bundled webview, so a phone scanning the QR can't
// load the React /mobile-scanner route from the local backend. Cache the self-contained
// static upload page to serve at that route in desktop mode instead.
this.cachedMobileUploadHtml = readStaticHtml("mobile-upload.html");
this.mobileUploadHtmlExists = this.cachedMobileUploadHtml != null;
// Check for external index.html first (customFiles/static/)
Path externalIndexPath = Path.of(InstallationPathConfig.getStaticPath(), "index.html");
log.debug("Checking for custom index.html at: {}", externalIndexPath);
@@ -144,6 +152,28 @@ public class ReactRoutingController {
return new ClassPathResource("static/index.html");
}
private String readStaticHtml(String filename) {
try {
Path external = Path.of(InstallationPathConfig.getStaticPath(), filename);
if (Files.exists(external) && Files.isReadable(external)) {
return Files.readString(external, StandardCharsets.UTF_8);
}
ClassPathResource resource = new ClassPathResource("static/" + filename);
if (resource.exists()) {
try (InputStream in = resource.getInputStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
} catch (Exception ex) {
log.warn("Failed to read static HTML {}", filename, ex);
}
return null;
}
private static boolean isDesktopMode() {
return Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"));
}
@GetMapping(
value = {"/", "/index.html"},
produces = MediaType.TEXT_HTML_VALUE)
@@ -191,6 +221,17 @@ public class ReactRoutingController {
return serveIndexHtml(request);
}
@GetMapping(value = "/mobile-scanner", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveMobileScanner(HttpServletRequest request) {
if (isDesktopMode() && mobileUploadHtmlExists) {
return ResponseEntity.ok()
.cacheControl(CacheControl.noCache().mustRevalidate())
.contentType(MediaType.TEXT_HTML)
.body(cachedMobileUploadHtml);
}
return serveIndexHtml(request);
}
@GetMapping(value = "/auth/callback/tauri", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveTauriAuthCallback(HttpServletRequest request) {
// cachedCallbackHtml is always initialized in @PostConstruct
@@ -0,0 +1,66 @@
package stirling.software.SPDF.service;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.PostHogService;
@Service
public class PdfMetricsService {
private final PostHogService postHogService;
private final ApplicationProperties applicationProperties;
private final AtomicLong operations = new AtomicLong();
private final AtomicLong pdfs = new AtomicLong();
private long lastOperations;
private long lastPdfs;
public PdfMetricsService(
PostHogService postHogService, ApplicationProperties applicationProperties) {
this.postHogService = postHogService;
this.applicationProperties = applicationProperties;
}
public boolean isEnabled() {
return applicationProperties.getSystem().isPosthogEnabled();
}
public void recordOperation(int pdfCount) {
if (!isEnabled()) {
return;
}
operations.incrementAndGet();
if (pdfCount > 0) {
pdfs.addAndGet(pdfCount);
}
}
@Scheduled(fixedRate = 7200000)
public void flushMetrics() {
if (!isEnabled()) {
return;
}
long curOps = operations.get();
long curPdfs = pdfs.get();
long opsDelta = curOps - lastOperations;
long pdfsDelta = curPdfs - lastPdfs;
if (opsDelta <= 0 && pdfsDelta <= 0) {
return;
}
Map<String, Object> props = new HashMap<>();
props.put("source", "api");
props.put("operations", opsDelta);
props.put("pdfs", pdfsDelta);
postHogService.captureEvent("pdf_operation_metrics", props);
lastOperations = curOps;
lastPdfs = curPdfs;
}
}
@@ -2215,6 +2215,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-security-oauth2-resource-server",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "4.0.6",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-servlet",
"moduleUrl": "https://spring.io/projects/spring-boot",
@@ -2320,6 +2327,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-oauth2-resource-server",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "4.0.6",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-security",
"moduleUrl": "https://spring.io/projects/spring-boot",
@@ -2438,6 +2452,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-oauth2-resource-server",
"moduleUrl": "https://spring.io/projects/spring-security",
"moduleVersion": "7.0.5",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-saml2-service-provider",
"moduleUrl": "https://spring.io/projects/spring-security",
File diff suppressed because one or more lines are too long
@@ -0,0 +1,333 @@
package org.apache.pdfbox.examples.signature;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.math.BigInteger;
import java.net.URL;
import java.net.URLConnection;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.MessageDigest;
import java.security.Security;
import java.security.cert.X509Certificate;
import java.util.Date;
import java.util.Set;
import javax.security.auth.x500.X500Principal;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.KeyPurposeId;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.DigestCalculator;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
import org.bouncycastle.tsp.TimeStampRequest;
import org.bouncycastle.tsp.TimeStampResponse;
import org.bouncycastle.tsp.TimeStampResponseGenerator;
import org.bouncycastle.tsp.TimeStampTokenGenerator;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Exercises the vendored PDFBox {@link TSAClient}. The TSA HTTP boundary is replaced with a mocked
* {@link URL}/{@link URLConnection} so no real network is ever contacted. A real BouncyCastle TSA
* response is generated in-process to drive the success path.
*/
@DisplayName("TSAClient (vendored PDFBox) Tests")
class TSAClientTest {
private static KeyPair tsaKeyPair;
private static X509Certificate tsaCert;
@BeforeAll
static void setUpProviderAndCert() throws Exception {
if (Security.getProvider("BC") == null) {
Security.addProvider(new BouncyCastleProvider());
}
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
tsaKeyPair = kpg.generateKeyPair();
tsaCert = selfSignedCert(tsaKeyPair);
}
private static X509Certificate selfSignedCert(KeyPair kp) throws Exception {
X500Principal dn = new X500Principal("CN=Test TSA");
long now = System.currentTimeMillis();
Date from = new Date(now - 1000L);
Date to = new Date(now + 365L * 24 * 60 * 60 * 1000);
BigInteger serial = BigInteger.valueOf(now);
ContentSigner signer = new JcaContentSignerBuilder("SHA256WithRSA").build(kp.getPrivate());
JcaX509v3CertificateBuilder builder =
new JcaX509v3CertificateBuilder(dn, serial, from, to, dn, kp.getPublic());
// RFC 3161 requires the TSA signing cert to carry a critical id-kp-timeStamping EKU.
builder.addExtension(
Extension.extendedKeyUsage,
true,
new ExtendedKeyUsage(KeyPurposeId.id_kp_timeStamping));
X509CertificateHolder holder = builder.build(signer);
return new JcaX509CertificateConverter().setProvider("BC").getCertificate(holder);
}
/** Builds a valid RFC 3161 timestamp response matching the supplied request bytes. */
private static byte[] buildTsaResponse(byte[] requestBytes) throws Exception {
TimeStampRequest request = new TimeStampRequest(requestBytes);
// SHA-1 digest calculator for the token's messageImprint of the signer cert.
DigestCalculator sha1 =
new JcaDigestCalculatorProviderBuilder()
.setProvider("BC")
.build()
.get(
new AlgorithmIdentifier(
org.bouncycastle.asn1.oiw.OIWObjectIdentifiers.idSHA1));
ContentSigner signer =
new JcaContentSignerBuilder("SHA256WithRSA").build(tsaKeyPair.getPrivate());
TimeStampTokenGenerator tokenGen =
new TimeStampTokenGenerator(
new org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder(
new JcaDigestCalculatorProviderBuilder()
.setProvider("BC")
.build())
.build(signer, tsaCert),
sha1,
new org.bouncycastle.asn1.ASN1ObjectIdentifier("1.2.3.4.1"));
// Embed the signer certificate so the response token validates standalone.
tokenGen.addCertificates(
new org.bouncycastle.cert.jcajce.JcaCertStore(java.util.List.of(tsaCert)));
// Accept the SHA-256 digest used by the request's messageImprint.
Set<String> acceptedAlgorithms =
Set.of(org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_sha256.getId());
TimeStampResponseGenerator responseGen =
new TimeStampResponseGenerator(tokenGen, acceptedAlgorithms);
TimeStampResponse response = responseGen.generate(request, BigInteger.ONE, new Date());
return response.getEncoded();
}
private TSAClient newClient(URL url, String username, String password) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return new TSAClient(url, username, password, digest);
}
@Nested
@DisplayName("Successful timestamp request")
class SuccessTests {
@Test
@DisplayName("Returns a parsed time stamp token from a valid TSA response")
void returnsTokenOnValidResponse() throws Exception {
URL url = mock(URL.class);
URLConnection connection = mock(URLConnection.class);
when(url.openConnection()).thenReturn(connection);
CapturingOutputStream sink = new CapturingOutputStream();
when(connection.getOutputStream()).thenReturn(sink);
// Lazily build the response based on the request actually written to the connection.
ResponseSupplierInputStream responseStream = new ResponseSupplierInputStream(sink);
when(connection.getInputStream()).thenReturn(responseStream);
TSAClient client = newClient(url, null, null);
var token = client.getTimeStampToken(new ByteArrayInputStream("hello pdf".getBytes()));
assertThat(token).isNotNull();
assertThat(token.getTimeStampInfo()).isNotNull();
// Content-Type header is always set for the timestamp query.
verify(connection).setRequestProperty("Content-Type", "application/timestamp-query");
verify(connection).setDoOutput(true);
verify(connection).setDoInput(true);
}
@Test
@DisplayName("Sends a Basic Authorization header when credentials are supplied")
void addsBasicAuthHeaderWhenCredentialsPresent() throws Exception {
URL url = mock(URL.class);
URLConnection connection = mock(URLConnection.class);
when(url.openConnection()).thenReturn(connection);
when(connection.getContentEncoding()).thenReturn(null);
CapturingOutputStream sink = new CapturingOutputStream();
when(connection.getOutputStream()).thenReturn(sink);
when(connection.getInputStream()).thenReturn(new ResponseSupplierInputStream(sink));
TSAClient client = newClient(url, "user", "secret");
client.getTimeStampToken(new ByteArrayInputStream("data".getBytes()));
verify(connection)
.setRequestProperty(
org.mockito.ArgumentMatchers.eq("Authorization"),
org.mockito.ArgumentMatchers.startsWith("Basic "));
}
@Test
@DisplayName("Does not send Authorization header when username is empty")
void noAuthHeaderWhenUsernameEmpty() throws Exception {
URL url = mock(URL.class);
URLConnection connection = mock(URLConnection.class);
when(url.openConnection()).thenReturn(connection);
CapturingOutputStream sink = new CapturingOutputStream();
when(connection.getOutputStream()).thenReturn(sink);
when(connection.getInputStream()).thenReturn(new ResponseSupplierInputStream(sink));
TSAClient client = newClient(url, "", "secret");
client.getTimeStampToken(new ByteArrayInputStream("data".getBytes()));
verify(connection, never())
.setRequestProperty(org.mockito.ArgumentMatchers.eq("Authorization"), any());
}
}
@Nested
@DisplayName("Error handling")
class ErrorTests {
@Test
@DisplayName("Propagates IOException raised while writing the request")
void propagatesWriteFailure() throws Exception {
URL url = mock(URL.class);
URLConnection connection = mock(URLConnection.class);
when(url.openConnection()).thenReturn(connection);
OutputStream failing =
new OutputStream() {
@Override
public void write(int b) throws IOException {
throw new IOException("write boom");
}
};
when(connection.getOutputStream()).thenReturn(failing);
TSAClient client = newClient(url, null, null);
assertThatThrownBy(
() ->
client.getTimeStampToken(
new ByteArrayInputStream("data".getBytes())))
.isInstanceOf(IOException.class)
.hasMessageContaining("write boom");
}
@Test
@DisplayName("Propagates IOException raised while reading the response")
void propagatesReadFailure() throws Exception {
URL url = mock(URL.class);
URLConnection connection = mock(URLConnection.class);
when(url.openConnection()).thenReturn(connection);
when(connection.getOutputStream()).thenReturn(new CapturingOutputStream());
when(connection.getInputStream()).thenThrow(new IOException("read boom"));
TSAClient client = newClient(url, null, null);
assertThatThrownBy(
() ->
client.getTimeStampToken(
new ByteArrayInputStream("data".getBytes())))
.isInstanceOf(IOException.class)
.hasMessageContaining("read boom");
}
@Test
@DisplayName("Wraps a malformed (non-TSP) response as an IOException")
void wrapsMalformedResponse() throws Exception {
URL url = mock(URL.class);
URLConnection connection = mock(URLConnection.class);
when(url.openConnection()).thenReturn(connection);
when(connection.getOutputStream()).thenReturn(new CapturingOutputStream());
when(connection.getInputStream())
.thenReturn(new ByteArrayInputStream("not a tsp response".getBytes()));
TSAClient client = newClient(url, null, null);
assertThatThrownBy(
() ->
client.getTimeStampToken(
new ByteArrayInputStream("data".getBytes())))
.isInstanceOf(IOException.class);
}
@Test
@DisplayName("Throws when the connection cannot be opened")
void throwsWhenConnectionFails() throws Exception {
URL url = mock(URL.class);
when(url.openConnection()).thenThrow(new IOException("no route"));
TSAClient client = newClient(url, null, null);
assertThatThrownBy(
() ->
client.getTimeStampToken(
new ByteArrayInputStream("data".getBytes())))
.isInstanceOf(IOException.class)
.hasMessageContaining("no route");
}
}
/** Collects everything written so the matching TSA response can be generated afterward. */
private static final class CapturingOutputStream extends OutputStream {
private final ByteArrayOutputStream delegate = new ByteArrayOutputStream();
@Override
public void write(int b) {
delegate.write(b);
}
@Override
public void write(byte[] b, int off, int len) {
delegate.write(b, off, len);
}
byte[] toByteArray() {
return delegate.toByteArray();
}
}
/** Lazily builds the TSA response from the request captured by the sink on first read. */
private static final class ResponseSupplierInputStream extends java.io.InputStream {
private final CapturingOutputStream sink;
private ByteArrayInputStream delegate;
ResponseSupplierInputStream(CapturingOutputStream sink) {
this.sink = sink;
}
private ByteArrayInputStream delegate() {
if (delegate == null) {
try {
delegate = new ByteArrayInputStream(buildTsaResponse(sink.toByteArray()));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
return delegate;
}
@Override
public int read() {
return delegate().read();
}
@Override
public int read(byte[] b, int off, int len) {
return delegate().read(b, off, len);
}
}
}
@@ -0,0 +1,35 @@
package org.apache.pdfbox.examples.signature;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link ValidationTimeStamp}. Only the constructor is exercised; it builds a
* TSAClient object without performing any network I/O.
*/
class ValidationTimeStampTest {
@Test
@DisplayName("null tsaUrl leaves the client unset and constructs cleanly")
void nullUrl() throws Exception {
ValidationTimeStamp vts = new ValidationTimeStamp(null);
assertThat(vts).isNotNull();
}
@Test
@DisplayName("valid tsaUrl builds the timestamp client without contacting the network")
void validUrl() throws Exception {
ValidationTimeStamp vts = new ValidationTimeStamp("http://timestamp.example.com/tsa");
assertThat(vts).isNotNull();
}
@Test
@DisplayName("malformed tsaUrl is rejected with an exception")
void malformedUrl() {
assertThatThrownBy(() -> new ValidationTimeStamp("http:// bad host/tsa"))
.isInstanceOf(Exception.class);
}
}
@@ -0,0 +1,186 @@
package stirling.software.SPDF;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import java.lang.reflect.Field;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import io.github.pixee.security.SystemCommand;
/**
* Unit tests for {@link LibreOfficeListener}. The process, socket and SystemCommand boundaries are
* mocked so no real soffice/unoconv process or network connection is ever created.
*/
@DisplayName("LibreOfficeListener")
class LibreOfficeListenerTest {
private LibreOfficeListener listener;
@BeforeEach
void setUp() {
listener = LibreOfficeListener.getInstance();
}
@AfterEach
void tearDown() throws Exception {
// Reset the singleton's mutable state so tests stay independent.
ExecutorService es = readExecutor();
if (es != null) {
es.shutdownNow();
}
setField("process", null);
setField("executorService", null);
}
private void setField(String name, Object value) throws Exception {
Field f = LibreOfficeListener.class.getDeclaredField(name);
f.setAccessible(true);
f.set(listener, value);
}
private Object readField(String name) throws Exception {
Field f = LibreOfficeListener.class.getDeclaredField(name);
f.setAccessible(true);
return f.get(listener);
}
private ExecutorService readExecutor() throws Exception {
return (ExecutorService) readField("executorService");
}
@Nested
@DisplayName("getInstance")
class GetInstance {
@Test
@DisplayName("always returns the same singleton instance")
void singletonIsStable() {
assertThat(LibreOfficeListener.getInstance())
.isSameAs(LibreOfficeListener.getInstance());
}
}
@Nested
@DisplayName("start")
class Start {
@Test
@DisplayName("returns immediately when a live process already exists")
void alreadyRunningShortCircuits() throws Exception {
Process alive = Mockito.mock(Process.class);
Mockito.when(alive.isAlive()).thenReturn(true);
setField("process", alive);
try (MockedStatic<SystemCommand> sys = Mockito.mockStatic(SystemCommand.class)) {
listener.start();
// No new process is spawned when one is already alive.
sys.verifyNoInteractions();
assertThat(readField("process")).isSameAs(alive);
assertThat(readExecutor()).isNull();
}
}
@Test
@DisplayName("spawns the listener and returns once the socket connects")
void spawnsAndDetectsRunningListener() throws Exception {
Process spawned = Mockito.mock(Process.class);
try (MockedStatic<SystemCommand> sys = Mockito.mockStatic(SystemCommand.class);
MockedConstruction<Socket> socket = Mockito.mockConstruction(Socket.class)) {
sys.when(() -> SystemCommand.runCommand(any(Runtime.class), anyString()))
.thenReturn(spawned);
listener.start();
// The spawned process is retained and the monitor executor is created.
assertThat(readField("process")).isSameAs(spawned);
assertThat(readExecutor()).isNotNull();
// A socket was constructed and connected exactly once on the first poll.
assertThat(socket.constructed()).hasSize(1);
Mockito.verify(socket.constructed().get(0)).connect(any(), eq(1000));
sys.verify(
() ->
SystemCommand.runCommand(
any(Runtime.class), eq("unoconv --listener")));
}
}
@Test
@DisplayName("retries until the listener socket becomes reachable")
void retriesUntilSocketReachable() throws Exception {
Process spawned = Mockito.mock(Process.class);
// First socket fails to connect, second succeeds; start() should poll twice.
try (MockedStatic<SystemCommand> sys = Mockito.mockStatic(SystemCommand.class);
MockedConstruction<Socket> socket =
Mockito.mockConstruction(
Socket.class,
(mock, ctx) -> {
if (ctx.getCount() == 1) {
Mockito.doThrow(new java.io.IOException("refused"))
.when(mock)
.connect(any(), eq(1000));
}
})) {
sys.when(() -> SystemCommand.runCommand(any(Runtime.class), anyString()))
.thenReturn(spawned);
listener.start();
// At least two sockets were attempted before one connected.
assertThat(socket.constructed().size()).isGreaterThanOrEqualTo(2);
}
}
}
@Nested
@DisplayName("stop")
class Stop {
@Test
@DisplayName("shuts down the monitor and destroys a live process")
void destroysLiveProcess() throws Exception {
Process alive = Mockito.mock(Process.class);
Mockito.when(alive.isAlive()).thenReturn(true);
ExecutorService es = Mockito.mock(ExecutorService.class);
setField("process", alive);
setField("executorService", es);
listener.stop();
Mockito.verify(es).shutdownNow();
Mockito.verify(alive).destroy();
}
@Test
@DisplayName("does not destroy a process that is already dead")
void skipsDestroyWhenProcessDead() throws Exception {
Process dead = Mockito.mock(Process.class);
Mockito.when(dead.isAlive()).thenReturn(false);
ExecutorService es = Mockito.mock(ExecutorService.class);
setField("process", dead);
setField("executorService", es);
listener.stop();
Mockito.verify(es).shutdownNow();
Mockito.verify(dead, Mockito.never()).destroy();
}
}
}
@@ -0,0 +1,168 @@
package stirling.software.SPDF;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.lang.reflect.Method;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.env.Environment;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
/**
* Remaining static-helper and lifecycle coverage for {@link SPDFApplication} that the existing
* {@code SPDFApplicationMoreTest} does not reach: profile selection, classpath probing, the
* setServerPortStatic auto/explicit branches and the non-Tauri init path.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("SPDFApplication remaining coverage")
class SPDFApplicationExtraTest {
@Mock private AppConfig appConfig;
@Mock private Environment env;
@Mock private ApplicationProperties applicationProperties;
private static Object invokeStatic(String name, Class<?>[] sig, Object... args)
throws Exception {
Method m = SPDFApplication.class.getDeclaredMethod(name, sig);
m.setAccessible(true);
return m.invoke(null, args);
}
@Nested
@DisplayName("getActiveProfile")
class GetActiveProfile {
private String[] activeProfile(String[] args) throws Exception {
return (String[])
invokeStatic(
"getActiveProfile", new Class<?>[] {String[].class}, (Object) args);
}
@Test
@DisplayName("explicit --spring.profiles.active wins over classpath detection")
void explicitProfile() throws Exception {
String[] result = activeProfile(new String[] {"--spring.profiles.active=foo,bar"});
assertThat(result).containsExactly("foo", "bar");
}
@Test
@DisplayName("a single explicit profile is honoured")
void singleExplicitProfile() throws Exception {
String[] result = activeProfile(new String[] {"--spring.profiles.active=custom"});
assertThat(result).containsExactly("custom");
}
@Test
@DisplayName("null args fall through to classpath-based detection")
void nullArgsFallThrough() throws Exception {
// Falls through to classpath detection; exact profile depends on the build flavor.
String[] result = activeProfile(null);
assertThat(result).isNotNull().isNotEmpty();
}
@Test
@DisplayName("no profile arg falls through to classpath-based detection")
void noProfileArgFallThrough() throws Exception {
String[] result = activeProfile(new String[] {"--server.port=9090"});
assertThat(result).isNotNull().isNotEmpty();
}
}
@Nested
@DisplayName("isClassPresent")
class IsClassPresent {
private boolean present(String className) throws Exception {
return (boolean)
invokeStatic("isClassPresent", new Class<?>[] {String.class}, className);
}
@Test
@DisplayName("returns true for a class on the classpath")
void existingClass() throws Exception {
assertThat(present("stirling.software.SPDF.SPDFApplication")).isTrue();
}
@Test
@DisplayName("returns false for a missing class")
void missingClass() throws Exception {
assertThat(present("com.example.totally.Missing")).isFalse();
}
}
@Nested
@DisplayName("setServerPortStatic")
class SetServerPortStatic {
@Test
@DisplayName("'auto' maps to Spring's 0 (auto-assign) port")
void autoMapsToZero() {
SPDFApplication.setServerPortStatic("auto");
assertThat(SPDFApplication.getStaticPort()).isEqualTo("0");
}
@Test
@DisplayName("'AUTO' is matched case-insensitively")
void autoCaseInsensitive() {
SPDFApplication.setServerPortStatic("AUTO");
assertThat(SPDFApplication.getStaticPort()).isEqualTo("0");
}
@Test
@DisplayName("an explicit port is stored verbatim")
void explicitPort() {
SPDFApplication.setServerPortStatic("8443");
assertThat(SPDFApplication.getStaticPort()).isEqualTo("8443");
}
}
@Nested
@DisplayName("init (non-Tauri, browser disabled)")
class InitNonTauri {
@Test
@DisplayName("populates the static URL fields without opening a browser")
void initBrowserDisabled() {
System.clearProperty("STIRLING_PDF_TAURI_MODE");
when(appConfig.getBackendUrl()).thenReturn("http://localhost");
when(appConfig.getContextPath()).thenReturn("/app");
when(appConfig.getServerPort()).thenReturn("9000");
when(env.getProperty("BROWSER_OPEN")).thenReturn(null);
SPDFApplication app = new SPDFApplication(appConfig, env, applicationProperties);
app.init();
assertThat(SPDFApplication.getStaticBaseUrl()).isEqualTo("http://localhost:9000");
assertThat(SPDFApplication.getStaticContextPath()).isEqualTo("/app");
assertThat(SPDFApplication.getStaticPort()).isEqualTo("9000");
}
}
@Test
@DisplayName("getStaticBaseUrl reflects the most recent init")
void staticBaseUrlReflectsInit() {
AppConfig cfg = mock(AppConfig.class);
when(cfg.getBackendUrl()).thenReturn("https://example.org");
when(cfg.getContextPath()).thenReturn("/");
when(cfg.getServerPort()).thenReturn("443");
Environment e = mock(Environment.class);
when(e.getProperty("BROWSER_OPEN")).thenReturn("false");
new SPDFApplication(cfg, e, applicationProperties).init();
// default https port 443 is omitted from the normalized base url
assertThat(SPDFApplication.getStaticBaseUrl()).isEqualTo("https://example.org");
}
}
@@ -0,0 +1,237 @@
package stirling.software.SPDF;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.lang.reflect.Method;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.core.env.Environment;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
/** Static URL helpers and lifecycle branches of SPDFApplication that need no Spring context. */
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("SPDFApplication extra coverage")
class SPDFApplicationMoreTest {
private static final String TAURI_PROP = "STIRLING_PDF_TAURI_MODE";
private static final String BROWSER_OPEN = "BROWSER_OPEN";
@Mock private AppConfig appConfig;
@Mock private Environment env;
@Mock private ApplicationProperties applicationProperties;
private String originalTauri;
@BeforeEach
void setUp() {
originalTauri = System.getProperty(TAURI_PROP);
System.clearProperty(TAURI_PROP);
}
@AfterEach
void tearDown() {
if (originalTauri == null) {
System.clearProperty(TAURI_PROP);
} else {
System.setProperty(TAURI_PROP, originalTauri);
}
}
private static Object invokeStatic(String name, Class<?>[] sig, Object... args)
throws Exception {
Method m = SPDFApplication.class.getDeclaredMethod(name, sig);
m.setAccessible(true);
return m.invoke(null, args);
}
@Nested
@DisplayName("normalizeBackendUrl")
class NormalizeBackendUrl {
private String normalize(String url, String port) throws Exception {
return (String)
invokeStatic(
"normalizeBackendUrl",
new Class<?>[] {String.class, String.class},
url,
port);
}
@Test
@DisplayName("blank backend url defaults to localhost with port")
void blankDefaultsLocalhost() throws Exception {
assertThat(normalize("", "8080")).isEqualTo("http://localhost:8080");
}
@Test
@DisplayName("adds scheme when missing and appends non-default port")
void addsSchemeAndPort() throws Exception {
assertThat(normalize("example.com", "9000")).isEqualTo("http://example.com:9000");
}
@Test
@DisplayName("omits default http port 80")
void omitsDefaultHttpPort() throws Exception {
assertThat(normalize("http://example.com", "80")).isEqualTo("http://example.com");
}
@Test
@DisplayName("omits default https port 443")
void omitsDefaultHttpsPort() throws Exception {
assertThat(normalize("https://example.com", "443")).isEqualTo("https://example.com");
}
@Test
@DisplayName("strips trailing slashes")
void stripsTrailingSlash() throws Exception {
assertThat(normalize("http://example.com///", "80")).isEqualTo("http://example.com");
}
@Test
@DisplayName("keeps an explicit port already in the url")
void keepsExplicitPort() throws Exception {
assertThat(normalize("http://example.com:1234", null))
.isEqualTo("http://example.com:1234");
}
}
@Nested
@DisplayName("buildFullUrl")
class BuildFullUrl {
private String build(String base, String port, String ctx) throws Exception {
return (String)
invokeStatic(
"buildFullUrl",
new Class<?>[] {String.class, String.class, String.class},
base,
port,
ctx);
}
@Test
@DisplayName("root context path yields a single trailing slash")
void rootContext() throws Exception {
assertThat(build("http://localhost", "8080", "/")).isEqualTo("http://localhost:8080/");
}
@Test
@DisplayName("non-root context path is prefixed with a slash")
void prefixesContext() throws Exception {
assertThat(build("http://localhost", "8080", "app"))
.isEqualTo("http://localhost:8080/app");
}
@Test
@DisplayName("blank context path treated as root")
void blankContext() throws Exception {
assertThat(build("http://localhost", "8080", "")).isEqualTo("http://localhost:8080/");
}
}
@Nested
@DisplayName("parsePort")
class ParsePort {
private Integer parse(String port) throws Exception {
return (Integer) invokeStatic("parsePort", new Class<?>[] {String.class}, port);
}
@Test
@DisplayName("parses a positive port")
void positive() throws Exception {
assertThat(parse("8080")).isEqualTo(8080);
}
@Test
@DisplayName("null for blank, non-numeric, zero, and negative")
void invalidInputs() throws Exception {
assertThat(parse("")).isNull();
assertThat(parse("abc")).isNull();
assertThat(parse("0")).isNull();
assertThat(parse("-5")).isNull();
}
}
@Nested
@DisplayName("appendPortFallback")
class AppendPortFallback {
private String append(String base, Integer port) throws Exception {
return (String)
invokeStatic(
"appendPortFallback",
new Class<?>[] {String.class, Integer.class},
base,
port);
}
@Test
@DisplayName("null port returns base unchanged")
void nullPort() throws Exception {
assertThat(append("http://host", null)).isEqualTo("http://host");
}
@Test
@DisplayName("base already ending in a port is unchanged")
void alreadyHasPort() throws Exception {
assertThat(append("http://host:1234", 80)).isEqualTo("http://host:1234");
}
@Test
@DisplayName("appends the port otherwise")
void appendsPort() throws Exception {
assertThat(append("http://host", 8080)).isEqualTo("http://host:8080");
}
}
@Nested
@DisplayName("lifecycle")
class Lifecycle {
@Test
@DisplayName("onApplicationReady picks up the runtime local.server.port")
void onApplicationReadyUsesRuntimePort() {
ApplicationReadyEvent event = mock(ApplicationReadyEvent.class, RETURNS_DEEP_STUBS);
when(event.getApplicationContext().getEnvironment().getProperty("local.server.port"))
.thenReturn("44444");
SPDFApplication app = new SPDFApplication(appConfig, env, applicationProperties);
app.onApplicationReady(event);
assertThat(SPDFApplication.getStaticPort()).isEqualTo("44444");
}
@Test
@DisplayName("init in Tauri mode logs parent pid and sets static URLs")
void initTauriMode() {
System.setProperty(TAURI_PROP, "true");
when(appConfig.getBackendUrl()).thenReturn("http://localhost");
when(appConfig.getContextPath()).thenReturn("/");
when(appConfig.getServerPort()).thenReturn("8080");
when(env.getProperty(BROWSER_OPEN)).thenReturn("false");
SPDFApplication app = new SPDFApplication(appConfig, env, applicationProperties);
app.init();
assertThat(SPDFApplication.getStaticBaseUrl()).isEqualTo("http://localhost:8080");
assertThat(SPDFApplication.getStaticContextPath()).isEqualTo("/");
}
}
}
@@ -0,0 +1,188 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.support.StaticApplicationContext;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
@DisplayName("EndpointInspector (additional coverage)")
class EndpointInspectorMoreTest {
private ApplicationContext applicationContext;
private EndpointInspector inspector;
// Simple controller bean providing a handler method for HandlerMethod construction.
static class DummyController {
public String handle() {
return "ok";
}
}
@BeforeEach
void setUp() {
applicationContext = mock(ApplicationContext.class);
inspector = new EndpointInspector(applicationContext);
}
private HandlerMethod handlerMethod() throws Exception {
Method method = DummyController.class.getMethod("handle");
return new HandlerMethod(new DummyController(), method);
}
@SuppressWarnings("unchecked")
private Set<String> validEndpoints() throws Exception {
Field field = EndpointInspector.class.getDeclaredField("validGetEndpoints");
field.setAccessible(true);
return (Set<String>) field.get(inspector);
}
private void stubMapping(Map<RequestMappingInfo, HandlerMethod> handlerMethods) {
RequestMappingHandlerMapping mapping = mock(RequestMappingHandlerMapping.class);
when(mapping.getHandlerMethods()).thenReturn(handlerMethods);
Map<String, RequestMappingHandlerMapping> beans = new HashMap<>();
beans.put("requestMappingHandlerMapping", mapping);
when(applicationContext.getBeansOfType(RequestMappingHandlerMapping.class))
.thenReturn(beans);
}
@Nested
@DisplayName("onApplicationEvent")
class OnApplicationEvent {
@Test
@DisplayName("discovers endpoints exactly once across repeated events")
void discoversOnce() throws Exception {
stubMapping(new LinkedHashMap<>());
ContextRefreshedEvent event = new ContextRefreshedEvent(new StaticApplicationContext());
inspector.onApplicationEvent(event);
inspector.onApplicationEvent(event);
Field discovered = EndpointInspector.class.getDeclaredField("endpointsDiscovered");
discovered.setAccessible(true);
assertThat(discovered.getBoolean(inspector)).isTrue();
}
}
@Nested
@DisplayName("discoverEndpoints")
class DiscoverEndpoints {
@Test
@DisplayName("collects direct paths from a GET mapping")
void collectsDirectPaths() throws Exception {
Map<RequestMappingInfo, HandlerMethod> methods = new LinkedHashMap<>();
RequestMappingInfo getInfo =
RequestMappingInfo.paths("/dashboard").methods(RequestMethod.GET).build();
methods.put(getInfo, handlerMethod());
stubMapping(methods);
Set<String> endpoints = inspector.getValidGetEndpoints();
assertThat(endpoints).contains("/dashboard");
}
@Test
@DisplayName("treats a mapping with no explicit method as a GET handler")
void noMethodCountsAsGet() throws Exception {
Map<RequestMappingInfo, HandlerMethod> methods = new LinkedHashMap<>();
RequestMappingInfo anyInfo = RequestMappingInfo.paths("/anything").build();
methods.put(anyInfo, handlerMethod());
stubMapping(methods);
assertThat(inspector.getValidGetEndpoints()).contains("/anything");
}
@Test
@DisplayName("ignores non-GET only mappings")
void ignoresPostOnly() throws Exception {
Map<RequestMappingInfo, HandlerMethod> methods = new LinkedHashMap<>();
RequestMappingInfo postInfo =
RequestMappingInfo.paths("/save").methods(RequestMethod.POST).build();
methods.put(postInfo, handlerMethod());
stubMapping(methods);
assertThat(inspector.getValidGetEndpoints()).doesNotContain("/save");
}
@Test
@DisplayName("falls back to string parsing for pattern-only mappings")
void fallsBackToStringParsing() throws Exception {
Map<RequestMappingInfo, HandlerMethod> methods = new LinkedHashMap<>();
// Wildcard patterns are not direct paths, forcing the toString() fallback branch.
RequestMappingInfo patternInfo =
RequestMappingInfo.paths("/files/**").methods(RequestMethod.GET).build();
methods.put(patternInfo, handlerMethod());
stubMapping(methods);
Set<String> endpoints = inspector.getValidGetEndpoints();
assertThat(endpoints).anySatisfy(p -> assertThat(p).contains("/files"));
}
}
@Nested
@DisplayName("getValidGetEndpoints")
class GetValidGetEndpoints {
@Test
@DisplayName("triggers discovery when not yet discovered")
void triggersDiscovery() throws Exception {
stubMapping(new LinkedHashMap<>());
Set<String> endpoints = inspector.getValidGetEndpoints();
// Empty discovery installs the fallback set.
assertThat(endpoints).contains("/", "/**", "/api/**");
}
}
@Nested
@DisplayName("matching helpers")
class MatchingHelpers {
@Test
@DisplayName("matchesPathSegments rejects a URI shorter than the pattern")
void shorterUriDoesNotMatch() throws Exception {
validEndpoints().clear();
validEndpoints().add("/api/v1/convert");
markDiscovered();
assertThat(inspector.isValidGetEndpoint("/api")).isFalse();
}
@Test
@DisplayName("wildcard with a star prefix matches the static portion")
void starPrefixMatches() throws Exception {
validEndpoints().clear();
validEndpoints().add("/static/*");
markDiscovered();
assertThat(inspector.isValidGetEndpoint("/static/app.js")).isTrue();
}
private void markDiscovered() throws Exception {
Field discovered = EndpointInspector.class.getDeclaredField("endpointsDiscovered");
discovered.setAccessible(true);
discovered.setBoolean(inspector, true);
}
}
}
@@ -0,0 +1,244 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedConstruction;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import stirling.software.common.configuration.RuntimePathConfig;
/**
* Pure-helper coverage for {@link ExternalAppDepConfig}: the inner Version comparator, the
* weasyprint/qpdf command recognisers, feature-name formatting and the findFirstAvailable probe
* loop. No real binaries run; ProcessBuilder is intercepted where a probe is required.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ExternalAppDepConfig helper coverage")
class ExternalAppDepConfigExtraTest {
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private RuntimePathConfig runtimePathConfig;
private ExternalAppDepConfig config;
@BeforeEach
void setUp() {
when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/opt/weasyprint");
when(runtimePathConfig.getUnoConvertPath()).thenReturn("/opt/unoconvert");
when(runtimePathConfig.getCalibrePath()).thenReturn("/opt/calibre");
when(runtimePathConfig.getOcrMyPdfPath()).thenReturn("/opt/ocrmypdf");
when(runtimePathConfig.getSOfficePath()).thenReturn("/opt/soffice");
lenient()
.when(endpointConfiguration.getEndpointsForGroup(anyString()))
.thenReturn(Set.of());
config = new ExternalAppDepConfig(endpointConfiguration, runtimePathConfig);
}
private Object invoke(String name, Class<?>[] sig, Object... args) throws Exception {
Method m = ExternalAppDepConfig.class.getDeclaredMethod(name, sig);
m.setAccessible(true);
return m.invoke(config, args);
}
private static Process processReturning(int exitCode, String stdout) {
Process p = mock(Process.class);
try {
doReturn(true).when(p).waitFor(anyLong(), any(TimeUnit.class));
} catch (InterruptedException ignored) {
// mock never throws
}
doReturn(exitCode).when(p).exitValue();
doAnswer(inv -> new ByteArrayInputStream(stdout.getBytes(StandardCharsets.UTF_8)))
.when(p)
.getInputStream();
doAnswer(inv -> new ByteArrayInputStream(new byte[0])).when(p).getErrorStream();
return p;
}
@Nested
@DisplayName("Version comparator")
class VersionComparator {
private Comparable<Object> version(String v) throws Exception {
Class<?> versionClass =
Class.forName("stirling.software.SPDF.config.ExternalAppDepConfig$Version");
Constructor<?> ctor = versionClass.getDeclaredConstructor(String.class);
ctor.setAccessible(true);
@SuppressWarnings("unchecked")
Comparable<Object> instance = (Comparable<Object>) ctor.newInstance(v);
return instance;
}
@Test
@DisplayName("orders by major, then minor, then patch")
void ordersBySegments() throws Exception {
assertThat(version("1.2.3").compareTo(version("1.2.4"))).isNegative();
assertThat(version("2.0.0").compareTo(version("1.9.9"))).isPositive();
assertThat(version("1.2.0").compareTo(version("1.2"))).isZero();
}
@Test
@DisplayName("treats equal versions as equal")
void equalVersions() throws Exception {
assertThat(version("58.0").compareTo(version("58.0.0"))).isZero();
}
@Test
@DisplayName("non-numeric segments are treated as zero")
void nonNumericSegmentsAsZero() throws Exception {
// "12.x" -> 12.0.0, equal to "12"
assertThat(version("12.x").compareTo(version("12"))).isZero();
}
@Test
@DisplayName("toString renders the three-segment form")
void toStringThreeSegments() throws Exception {
assertThat(version("11.9").toString()).isEqualTo("11.9.0");
}
}
@Nested
@DisplayName("command recognisers")
class CommandRecognisers {
private boolean isWeasyprint(String command) throws Exception {
return (boolean) invoke("isWeasyprint", new Class<?>[] {String.class}, command);
}
private boolean isQpdf(String command) throws Exception {
return (boolean) invoke("isQpdf", new Class<?>[] {String.class}, command);
}
@Test
@DisplayName("isWeasyprint matches the configured path and any name containing weasyprint")
void weasyprintMatches() throws Exception {
assertThat(isWeasyprint("/opt/weasyprint")).isTrue();
assertThat(isWeasyprint("WeasyPrint")).isTrue();
assertThat(isWeasyprint("gs")).isFalse();
}
@Test
@DisplayName("isQpdf matches any name containing qpdf, case-insensitively")
void qpdfMatches() throws Exception {
assertThat(isQpdf("qpdf")).isTrue();
assertThat(isQpdf("/usr/bin/QPDF")).isTrue();
assertThat(isQpdf("tesseract")).isFalse();
}
}
@Nested
@DisplayName("feature-name formatting")
class FeatureFormatting {
private String capitalizeWord(String word) throws Exception {
return (String) invoke("capitalizeWord", new Class<?>[] {String.class}, word);
}
private String formatEndpointAsFeature(String endpoint) throws Exception {
return (String)
invoke("formatEndpointAsFeature", new Class<?>[] {String.class}, endpoint);
}
@Test
@DisplayName("capitalizeWord upper-cases the first letter and lowers the rest")
void capitalizes() throws Exception {
assertThat(capitalizeWord("hello")).isEqualTo("Hello");
assertThat(capitalizeWord("WORLD")).isEqualTo("World");
}
@Test
@DisplayName("capitalizeWord keeps pdf fully upper-cased")
void capitalizesPdf() throws Exception {
assertThat(capitalizeWord("pdf")).isEqualTo("PDF");
assertThat(capitalizeWord("PDF")).isEqualTo("PDF");
}
@Test
@DisplayName("capitalizeWord returns null/empty unchanged")
void capitalizeEmpty() throws Exception {
assertThat(capitalizeWord("")).isEmpty();
assertThat(capitalizeWord((String) null)).isNull();
}
@Test
@DisplayName("formatEndpointAsFeature humanises a hyphenated endpoint with pdf/img mapping")
void humanisesEndpoint() throws Exception {
// "pdf-to-img" -> tokens pdf,to,image -> "PDF To Image"
assertThat(formatEndpointAsFeature("pdf-to-img")).isEqualTo("PDF To Image");
}
@Test
@DisplayName("formatEndpointAsFeature title-cases a plain endpoint")
void titleCasesPlain() throws Exception {
assertThat(formatEndpointAsFeature("merge-pdfs")).contains("Merge");
}
}
@Nested
@DisplayName("findFirstAvailable")
class FindFirstAvailable {
@SuppressWarnings("unchecked")
private Optional<String> findFirstAvailable(List<String> commands) throws Exception {
return (Optional<String>)
invoke("findFirstAvailable", new Class<?>[] {List.class}, commands);
}
@Test
@DisplayName("returns the first command whose lookup probe succeeds")
void returnsFirstSuccessful() throws Exception {
// python3 lookup fails (exit 1), python lookup succeeds (exit 0)
try (MockedConstruction<ProcessBuilder> ignored =
mockConstruction(
ProcessBuilder.class,
(pbMock, ctx) -> {
List<?> cmd = (List<?>) ctx.arguments().get(0);
boolean python = cmd.contains("python");
doReturn(processReturning(python ? 0 : 1, "")).when(pbMock).start();
})) {
Optional<String> result = findFirstAvailable(List.of("python3", "python"));
assertThat(result).contains("python");
}
}
@Test
@DisplayName("returns empty when no command is available")
void emptyWhenNoneAvailable() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
mockConstruction(
ProcessBuilder.class,
(pbMock, ctx) ->
doReturn(processReturning(1, "")).when(pbMock).start())) {
Optional<String> result = findFirstAvailable(List.of("nope1", "nope2"));
assertThat(result).isEmpty();
}
}
}
}
@@ -0,0 +1,315 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedConstruction;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import stirling.software.common.configuration.RuntimePathConfig;
/**
* Covers the process-probing paths of ExternalAppDepConfig by intercepting ProcessBuilder
* construction. No real external binaries are ever executed. Process mocks are wired with
* doReturn/doAnswer to avoid nested-when stubbing inside the mockConstruction initializer.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ExternalAppDepConfig extra coverage")
class ExternalAppDepConfigMoreTest {
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private RuntimePathConfig runtimePathConfig;
private ExternalAppDepConfig config;
@BeforeEach
void setUp() {
when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/custom/weasyprint");
when(runtimePathConfig.getUnoConvertPath()).thenReturn("/custom/unoconvert");
when(runtimePathConfig.getCalibrePath()).thenReturn("/custom/calibre");
when(runtimePathConfig.getOcrMyPdfPath()).thenReturn("/custom/ocrmypdf");
when(runtimePathConfig.getSOfficePath()).thenReturn("/custom/soffice");
lenient()
.when(endpointConfiguration.getEndpointsForGroup(anyString()))
.thenReturn(Set.of());
config = new ExternalAppDepConfig(endpointConfiguration, runtimePathConfig);
}
/**
* Build a Process mock that finishes with the given exit code and stream text. Uses doReturn so
* it can be safely called inside a mockConstruction initializer.
*/
private static Process processReturning(int exitCode, String stdout, String stderr) {
Process p = mock(Process.class);
try {
doReturn(true).when(p).waitFor(anyLong(), any(TimeUnit.class));
} catch (InterruptedException ignored) {
// mock never actually throws
}
doReturn(exitCode).when(p).exitValue();
// Fresh stream per call so concurrent/repeated reads never race on a consumed buffer.
doAnswer(inv -> new ByteArrayInputStream(stdout.getBytes(StandardCharsets.UTF_8)))
.when(p)
.getInputStream();
doAnswer(inv -> new ByteArrayInputStream(stderr.getBytes(StandardCharsets.UTF_8)))
.when(p)
.getErrorStream();
return p;
}
/** Make every ProcessBuilder built in scope return the supplied process from start(). */
private static MockedConstruction<ProcessBuilder> alwaysReturn(Process p) {
return mockConstruction(
ProcessBuilder.class,
(pbMock, ctx) -> {
try {
doReturn(p).when(pbMock).start();
} catch (Exception ignored) {
}
});
}
private Object invoke(String name, Class<?>[] sig, Object... args) throws Exception {
Method m = ExternalAppDepConfig.class.getDeclaredMethod(name, sig);
m.setAccessible(true);
return m.invoke(config, args);
}
@Nested
@DisplayName("isCommandAvailable")
class IsCommandAvailable {
@Test
@DisplayName("true when OS lookup (where/which) exits 0")
void availableViaLookup() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(0, "/usr/bin/gs", ""))) {
boolean available =
(boolean) invoke("isCommandAvailable", new Class<?>[] {String.class}, "gs");
assertThat(available).isTrue();
}
}
@Test
@DisplayName("falls back to --version when lookup fails, then true")
void availableViaVersionFallback() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
mockConstruction(
ProcessBuilder.class,
(pbMock, ctx) -> {
List<?> cmd = (List<?>) ctx.arguments().get(0);
boolean isVersion = cmd.contains("--version");
doReturn(processReturning(isVersion ? 0 : 1, "1.0", ""))
.when(pbMock)
.start();
})) {
boolean available =
(boolean)
invoke(
"isCommandAvailable",
new Class<?>[] {String.class},
"weirdcmd");
assertThat(available).isTrue();
}
}
@Test
@DisplayName("false when both lookup and --version fail")
void unavailable() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(1, "", ""))) {
boolean available =
(boolean)
invoke("isCommandAvailable", new Class<?>[] {String.class}, "nope");
assertThat(available).isFalse();
}
}
}
@Nested
@DisplayName("getVersionSafe")
class GetVersionSafe {
@Test
@DisplayName("extracts a version number from combined output")
@SuppressWarnings("unchecked")
void extractsVersion() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(0, "qpdf version 11.9.0", ""))) {
Optional<String> version =
(Optional<String>)
invoke(
"getVersionSafe",
new Class<?>[] {String.class, String.class},
"qpdf",
"--version");
assertThat(version).contains("11.9.0");
}
}
@Test
@DisplayName("empty when command exits non-zero")
@SuppressWarnings("unchecked")
void emptyOnNonZero() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(2, "", ""))) {
Optional<String> version =
(Optional<String>)
invoke(
"getVersionSafe",
new Class<?>[] {String.class, String.class},
"qpdf",
"--version");
assertThat(version).isEmpty();
}
}
}
@Nested
@DisplayName("runAndWait")
class RunAndWait {
@Test
@DisplayName("returns timeout code 124 and destroys the process when it does not finish")
void timeoutDestroysProcess() throws Exception {
Process p = mock(Process.class);
doReturn(false).when(p).waitFor(anyLong(), any(TimeUnit.class));
try (MockedConstruction<ProcessBuilder> ignored = alwaysReturn(p)) {
Object result =
invoke(
"runAndWait",
new Class<?>[] {List.class, Duration.class},
List.of("sleep", "100"),
Duration.ofMillis(10));
Method ec = result.getClass().getDeclaredMethod("exitCode");
ec.setAccessible(true);
assertThat((int) ec.invoke(result)).isEqualTo(124);
verify(p).destroyForcibly();
}
}
@Test
@DisplayName("returns code 127 when ProcessBuilder.start throws IOException")
void ioExceptionYields127() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
mockConstruction(
ProcessBuilder.class,
(pbMock, ctx) ->
when(pbMock.start())
.thenThrow(new java.io.IOException("cannot run")))) {
Object result =
invoke(
"runAndWait",
new Class<?>[] {List.class, Duration.class},
List.of("bogus"),
Duration.ofSeconds(1));
Method ec = result.getClass().getDeclaredMethod("exitCode");
ec.setAccessible(true);
assertThat((int) ec.invoke(result)).isEqualTo(127);
}
}
}
/**
* checkDependencyAndDisableGroup is tested directly (single-threaded). The public
* checkDependencies() fans probes out over virtual threads where Mockito's thread-confined
* MockedConstruction would not intercept, so it is not exercised here.
*/
@Nested
@DisplayName("checkDependencyAndDisableGroup")
class CheckDependencyAndDisableGroup {
@Test
@DisplayName("disables the affected group when the command is missing")
void disablesMissingGroup() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(1, "", ""))) {
invoke("checkDependencyAndDisableGroup", new Class<?>[] {String.class}, "gs");
}
verify(endpointConfiguration)
.disableGroup("Ghostscript", EndpointConfiguration.DisableReason.DEPENDENCY);
}
@Test
@DisplayName("present command with no version gate leaves the group enabled")
void presentCommandNotDisabled() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(0, "/usr/bin/gs", ""))) {
invoke("checkDependencyAndDisableGroup", new Class<?>[] {String.class}, "gs");
}
verify(endpointConfiguration, never())
.disableGroup(anyString(), eq(EndpointConfiguration.DisableReason.DEPENDENCY));
}
@Test
@DisplayName("qpdf below the required version disables the qpdf group")
void qpdfBelowMinimumDisabled() throws Exception {
// Lookup succeeds (exit 0) and --version reports an old release -> version gate fires.
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(0, "qpdf version 10.0.0", ""))) {
invoke("checkDependencyAndDisableGroup", new Class<?>[] {String.class}, "qpdf");
}
verify(endpointConfiguration)
.disableGroup("qpdf", EndpointConfiguration.DisableReason.DEPENDENCY);
}
@Test
@DisplayName("qpdf at or above the required version stays enabled")
void qpdfMeetsMinimumNotDisabled() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(0, "qpdf version 12.5.0", ""))) {
invoke("checkDependencyAndDisableGroup", new Class<?>[] {String.class}, "qpdf");
}
verify(endpointConfiguration, never())
.disableGroup(anyString(), eq(EndpointConfiguration.DisableReason.DEPENDENCY));
}
@Test
@DisplayName("weasyprint below the required version disables the weasyprint group")
void weasyprintBelowMinimumDisabled() throws Exception {
try (MockedConstruction<ProcessBuilder> ignored =
alwaysReturn(processReturning(0, "WeasyPrint 50.0", ""))) {
invoke(
"checkDependencyAndDisableGroup",
new Class<?>[] {String.class},
"/custom/weasyprint");
}
verify(endpointConfiguration)
.disableGroup("Weasyprint", EndpointConfiguration.DisableReason.DEPENDENCY);
}
}
}
@@ -0,0 +1,182 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
class InitialSetupTest {
private ApplicationProperties applicationProperties;
private ApplicationProperties.AutomaticallyGenerated autoGen;
private ApplicationProperties.Legal legal;
private InitialSetup initialSetup;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
autoGen = applicationProperties.getAutomaticallyGenerated();
legal = applicationProperties.getLegal();
initialSetup = new InitialSetup(applicationProperties);
}
@Nested
@DisplayName("initUUIDKey")
class UuidKey {
@Test
@DisplayName("generates and persists a UUID when missing")
void generatesWhenMissing() throws Exception {
autoGen.setUUID(null);
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
util.when(() -> GeneralUtils.isValidUUID(null)).thenReturn(false);
initialSetup.initUUIDKey();
assertThat(autoGen.getUUID()).isNotBlank();
util.verify(
() ->
GeneralUtils.saveKeyToSettings(
eq("AutomaticallyGenerated.UUID"), any()),
times(1));
}
}
@Test
@DisplayName("keeps an existing valid UUID")
void keepsValid() throws Exception {
autoGen.setUUID("existing");
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
util.when(() -> GeneralUtils.isValidUUID("existing")).thenReturn(true);
initialSetup.initUUIDKey();
assertThat(autoGen.getUUID()).isEqualTo("existing");
util.verify(() -> GeneralUtils.saveKeyToSettings(any(), any()), never());
}
}
}
@Nested
@DisplayName("initSecretKey")
class SecretKey {
@Test
@DisplayName("generates a key when invalid")
void generatesWhenInvalid() throws Exception {
autoGen.setKey(null);
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
util.when(() -> GeneralUtils.isValidUUID(null)).thenReturn(false);
initialSetup.initSecretKey();
assertThat(autoGen.getKey()).isNotBlank();
util.verify(
() ->
GeneralUtils.saveKeyToSettings(
eq("AutomaticallyGenerated.key"), any()),
times(1));
}
}
@Test
@DisplayName("keeps an existing valid key")
void keepsValid() throws Exception {
autoGen.setKey("secret");
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
util.when(() -> GeneralUtils.isValidUUID("secret")).thenReturn(true);
initialSetup.initSecretKey();
assertThat(autoGen.getKey()).isEqualTo("secret");
}
}
}
@Nested
@DisplayName("initLegalUrls")
class LegalUrls {
@Test
@DisplayName("sets defaults when both URLs are empty")
void setsDefaults() throws Exception {
legal.setTermsAndConditions(null);
legal.setPrivacyPolicy("");
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
initialSetup.initLegalUrls();
assertThat(legal.getTermsAndConditions()).contains("stirlingpdf.com/terms");
assertThat(legal.getPrivacyPolicy()).contains("privacy-policy");
util.verify(
() -> GeneralUtils.saveKeyToSettings(eq("legal.termsAndConditions"), any()),
times(1));
util.verify(
() -> GeneralUtils.saveKeyToSettings(eq("legal.privacyPolicy"), any()),
times(1));
}
}
@Test
@DisplayName("preserves already-configured URLs")
void preservesExisting() throws Exception {
legal.setTermsAndConditions("https://example.com/t");
legal.setPrivacyPolicy("https://example.com/p");
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
initialSetup.initLegalUrls();
assertThat(legal.getTermsAndConditions()).isEqualTo("https://example.com/t");
util.verify(() -> GeneralUtils.saveKeyToSettings(any(), any()), never());
}
}
}
@Nested
@DisplayName("initSetAppVersion")
class AppVersion {
@Test
@DisplayName("flags new server when version is missing")
void newServerWhenMissing() throws Exception {
autoGen.setAppVersion(null);
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
initialSetup.initSetAppVersion();
assertThat(autoGen.getIsNewServer()).isTrue();
assertThat(autoGen.getAppVersion()).isNotNull();
}
}
@Test
@DisplayName("flags new server when version is 0.0.0")
void newServerWhenZero() throws Exception {
autoGen.setAppVersion("0.0.0");
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
initialSetup.initSetAppVersion();
assertThat(autoGen.getIsNewServer()).isTrue();
}
}
@Test
@DisplayName("existing server keeps not-new flag")
void existingServer() throws Exception {
autoGen.setAppVersion("1.2.3");
try (MockedStatic<GeneralUtils> util = mockStatic(GeneralUtils.class)) {
initialSetup.initSetAppVersion();
assertThat(autoGen.getIsNewServer()).isFalse();
}
}
}
}
@@ -0,0 +1,86 @@
package stirling.software.SPDF.config;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
class MetricsFilterTest {
private SimpleMeterRegistry registry;
private MetricsFilter filter;
private HttpServletRequest request;
private HttpServletResponse response;
private FilterChain chain;
@BeforeEach
void setUp() {
registry = new SimpleMeterRegistry();
filter = new MetricsFilter(registry);
request = mock(HttpServletRequest.class);
response = mock(HttpServletResponse.class);
chain = mock(FilterChain.class);
}
@Nested
@DisplayName("trackable requests")
class Trackable {
@Test
@DisplayName("increments a counter for a trackable URI with session")
void countsWithSession() throws Exception {
HttpSession session = mock(HttpSession.class);
when(session.getId()).thenReturn("sess-1");
when(request.getRequestURI()).thenReturn("/api/v1/general/rotate-pdf");
when(request.getContextPath()).thenReturn("");
when(request.getMethod()).thenReturn("POST");
when(request.getSession(false)).thenReturn(session);
filter.doFilterInternal(request, response, chain);
verify(chain).doFilter(request, response);
}
@Test
@DisplayName("uses no-session tag when session is absent")
void countsWithoutSession() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/general/merge-pdfs");
when(request.getContextPath()).thenReturn("");
when(request.getMethod()).thenReturn("POST");
when(request.getSession(false)).thenReturn(null);
filter.doFilterInternal(request, response, chain);
verify(chain).doFilter(request, response);
}
}
@Nested
@DisplayName("non-trackable requests")
class NonTrackable {
@Test
@DisplayName("static resource is not counted but chain continues")
void staticResourceSkipped() throws Exception {
when(request.getRequestURI()).thenReturn("/css/style.css");
when(request.getContextPath()).thenReturn("");
filter.doFilterInternal(request, response, chain);
verify(chain).doFilter(request, response);
verify(request, never()).getMethod();
}
}
}
@@ -0,0 +1,62 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import jakarta.servlet.MultipartConfigElement;
import stirling.software.SPDF.controller.web.UploadLimitService;
class MultipartConfigurationTest {
private UploadLimitService uploadLimitService;
private MultipartConfiguration configuration;
@BeforeEach
void setUp() throws Exception {
// Manually constructed config with a mocked service, so Spring env overrides do not apply.
uploadLimitService = mock(UploadLimitService.class);
configuration = new MultipartConfiguration();
Field field = MultipartConfiguration.class.getDeclaredField("uploadLimitService");
field.setAccessible(true);
field.set(configuration, uploadLimitService);
}
@Nested
@DisplayName("multipartConfigElement")
class ConfigElement {
@Test
@DisplayName("uses the configured upload limit when positive")
void usesConfiguredLimit() {
long limit = 50L * 1024 * 1024;
when(uploadLimitService.getUploadLimit()).thenReturn(limit);
when(uploadLimitService.getReadableUploadLimit()).thenReturn("50.0 MB");
MultipartConfigElement element = configuration.multipartConfigElement();
assertThat(element.getMaxFileSize()).isEqualTo(limit);
assertThat(element.getMaxRequestSize()).isEqualTo(limit);
}
@Test
@DisplayName("falls back to 2000MB default when no limit configured")
void usesDefaultWhenZero() {
when(uploadLimitService.getUploadLimit()).thenReturn(0L);
MultipartConfigElement element = configuration.multipartConfigElement();
long expectedDefault = 2000L * 1024 * 1024;
assertThat(element.getMaxFileSize()).isEqualTo(expectedDefault);
assertThat(element.getMaxRequestSize()).isEqualTo(expectedDefault);
}
}
}
@@ -0,0 +1,149 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springdoc.core.customizers.OpenApiCustomizer;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.media.ComposedSchema;
import io.swagger.v3.oas.models.media.Schema;
import stirling.software.common.model.ApplicationProperties;
@DisplayName("OpenApiConfig")
class OpenApiConfigTest {
private ApplicationProperties applicationProperties;
private OpenApiConfig config;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
config = new OpenApiConfig(applicationProperties);
}
@Nested
@DisplayName("customOpenAPI")
class CustomOpenAPI {
@Test
@DisplayName("builds OpenAPI with title, version and 3.0.3 spec")
void buildsBaseOpenApi() {
OpenAPI openAPI = config.customOpenAPI();
assertThat(openAPI).isNotNull();
assertThat(openAPI.getOpenapi()).isEqualTo("3.0.3");
assertThat(openAPI.getInfo()).isNotNull();
assertThat(openAPI.getInfo().getTitle()).isEqualTo("Stirling PDF API");
// Version falls back to 1.0.0 when no implementation version on the package.
assertThat(openAPI.getInfo().getVersion()).isNotBlank();
}
@Test
@DisplayName("sets license, contact and terms of service")
void setsLicenseAndContact() {
OpenAPI openAPI = config.customOpenAPI();
assertThat(openAPI.getInfo().getLicense()).isNotNull();
assertThat(openAPI.getInfo().getLicense().getName()).contains("MIT");
assertThat(openAPI.getInfo().getTermsOfService())
.isEqualTo("https://www.stirlingpdf.com/terms");
assertThat(openAPI.getInfo().getContact()).isNotNull();
assertThat(openAPI.getInfo().getContact().getEmail())
.isEqualTo("contact@stirlingpdf.com");
}
@Test
@DisplayName("registers the global AI tag")
void registersAiTag() {
OpenAPI openAPI = config.customOpenAPI();
assertThat(openAPI.getTags()).isNotNull();
assertThat(openAPI.getTags())
.anySatisfy(tag -> assertThat(tag.getName()).isEqualTo("AI"));
}
@Test
@DisplayName("adds a server item and an ErrorResponse schema")
void addsServerAndErrorSchema() {
OpenAPI openAPI = config.customOpenAPI();
assertThat(openAPI.getServers()).isNotEmpty();
assertThat(openAPI.getServers().get(0).getUrl()).isNotBlank();
Components components = openAPI.getComponents();
assertThat(components).isNotNull();
assertThat(components.getSchemas()).containsKey("ErrorResponse");
Schema<?> errorSchema = components.getSchemas().get("ErrorResponse");
assertThat(errorSchema.getProperties())
.containsKeys("timestamp", "status", "error", "message", "path");
}
@Test
@DisplayName("uses relative server URL when SWAGGER_SERVER_URL env var is absent")
void usesRelativeServerWhenEnvAbsent() {
// The test JVM does not set SWAGGER_SERVER_URL so the relative branch runs.
if (System.getenv("SWAGGER_SERVER_URL") == null) {
OpenAPI openAPI = config.customOpenAPI();
assertThat(openAPI.getServers().get(0).getUrl()).isEqualTo("/");
assertThat(openAPI.getServers().get(0).getDescription())
.isEqualTo("Current Server");
}
}
@Test
@DisplayName("omits API-key security scheme when login is disabled")
void noSecuritySchemeWhenLoginDisabled() {
applicationProperties.getSecurity().setEnableLogin(false);
OpenAPI openAPI = config.customOpenAPI();
assertThat(openAPI.getComponents().getSecuritySchemes()).isNullOrEmpty();
assertThat(openAPI.getSecurity()).isNullOrEmpty();
}
@Test
@DisplayName("adds API-key security scheme when login is enabled")
void addsSecuritySchemeWhenLoginEnabled() {
applicationProperties.getSecurity().setEnableLogin(true);
OpenAPI openAPI = config.customOpenAPI();
assertThat(openAPI.getComponents().getSecuritySchemes()).containsKey("apiKey");
assertThat(openAPI.getSecurity()).isNotEmpty();
assertThat(openAPI.getSecurity().get(0)).containsKey("apiKey");
}
}
@Nested
@DisplayName("pdfFileOneOfCustomizer")
class PdfFileOneOfCustomizer {
@Test
@DisplayName("replaces PDFFile schema with a oneOf and registers upload/ref shapes")
void replacesPdfFileSchema() {
OpenApiCustomizer customizer = config.pdfFileOneOfCustomizer();
assertThat(customizer).isNotNull();
// Seed an OpenAPI with an existing PDFFile schema to be replaced.
OpenAPI openApi = new OpenAPI().components(new Components());
openApi.getComponents().addSchemas("PDFFile", new Schema<>().type("string"));
customizer.customise(openApi);
var schemas = openApi.getComponents().getSchemas();
assertThat(schemas).containsKeys("PDFFileUpload", "PDFFileRef", "PDFFile");
assertThat(schemas.get("PDFFile")).isInstanceOf(ComposedSchema.class);
ComposedSchema oneOf = (ComposedSchema) schemas.get("PDFFile");
assertThat(oneOf.getOneOf()).hasSize(2);
assertThat(schemas.get("PDFFileUpload").getRequired()).contains("fileInput");
assertThat(schemas.get("PDFFileRef").getRequired()).contains("fileId");
}
}
}
@@ -0,0 +1,107 @@
package stirling.software.SPDF.config;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import stirling.software.SPDF.service.PdfMetricsService;
class PdfMetricsInterceptorTest {
private PdfMetricsService service;
private PdfMetricsInterceptor interceptor;
@BeforeEach
void setUp() {
service = mock(PdfMetricsService.class);
when(service.isEnabled()).thenReturn(true);
interceptor = new PdfMetricsInterceptor(service);
}
private MultipartHttpServletRequest editRequest(int fileParts, String... headers) {
MultipartHttpServletRequest request = mock(MultipartHttpServletRequest.class);
when(request.getMethod()).thenReturn("POST");
when(request.getServletPath()).thenReturn("/api/v1/general/rotate-pdf");
for (int i = 0; i + 1 < headers.length; i += 2) {
when(request.getHeader(headers[i])).thenReturn(headers[i + 1]);
}
MultiValueMap<String, MultipartFile> files = new LinkedMultiValueMap<>();
for (int i = 0; i < fileParts; i++) {
files.add("fileInput", mock(MultipartFile.class));
}
when(request.getMultiFileMap()).thenReturn(files);
return request;
}
private HttpServletResponse response(int status, String contentType) {
HttpServletResponse response = mock(HttpServletResponse.class);
when(response.getStatus()).thenReturn(status);
when(response.getContentType()).thenReturn(contentType);
return response;
}
@Test
void apiRequestIsCounted() {
interceptor.afterCompletion(editRequest(1), response(200, "application/pdf"), null, null);
verify(service).recordOperation(1);
}
@Test
void countsEveryFilePartUnderOneFieldName() {
interceptor.afterCompletion(editRequest(3), response(200, "application/pdf"), null, null);
verify(service).recordOperation(3);
}
@Test
void countsRegardlessOfResponseType() {
interceptor.afterCompletion(editRequest(1), response(200, "application/json"), null, null);
verify(service).recordOperation(1);
}
@Test
void editorRequestWithBrowserIdIsNotCounted() {
interceptor.afterCompletion(
editRequest(1, "X-Browser-Id", "abc-123"),
response(200, "application/pdf"),
null,
null);
verify(service, never()).recordOperation(anyInt());
}
@Test
void editorJwtWithoutBrowserIdIsNotCounted() {
interceptor.afterCompletion(
editRequest(1, "Authorization", "Bearer eyJhbG.eyJzdWI.sig"),
response(200, "application/pdf"),
null,
null);
verify(service, never()).recordOperation(anyInt());
}
@Test
void bearerApiKeyIsCounted() {
interceptor.afterCompletion(
editRequest(1, "Authorization", "Bearer sk-not-a-jwt-key"),
response(200, "application/pdf"),
null,
null);
verify(service).recordOperation(1);
}
@Test
void errorResponseIsNotCounted() {
interceptor.afterCompletion(editRequest(1), response(500, "application/pdf"), null, null);
verify(service, never()).recordOperation(anyInt());
}
}
@@ -0,0 +1,119 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springdoc.core.customizers.OpenApiCustomizer;
import org.springdoc.core.models.GroupedOpenApi;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
@DisplayName("SpringDocConfig")
class SpringDocConfigTest {
private SpringDocConfig config;
@BeforeEach
void setUp() {
config = new SpringDocConfig();
}
// Applies every registered customizer against a fresh OpenAPI carrying an Info instance.
private OpenAPI applyCustomizers(GroupedOpenApi api) {
OpenAPI openApi = new OpenAPI().info(new Info());
for (OpenApiCustomizer customizer : api.getOpenApiCustomizers()) {
customizer.customise(openApi);
}
return openApi;
}
@Nested
@DisplayName("pdfProcessingApi")
class PdfProcessingApi {
@Test
@DisplayName("builds the file-processing group with match and exclude paths")
void buildsGroup() {
OpenApiCustomizer pdfFileOneOfCustomizer = openApi -> {};
GroupedOpenApi api = config.pdfProcessingApi(pdfFileOneOfCustomizer);
assertThat(api).isNotNull();
assertThat(api.getGroup()).isEqualTo("file-processing");
assertThat(api.getDisplayName()).isEqualTo("File Processing");
assertThat(api.getPathsToMatch()).contains("/api/v1/**");
assertThat(api.getPathsToExclude()).contains("/api/v1/admin/**", "/api/v1/auth/**");
// The injected oneOf customizer plus the inline info customizer are both registered.
assertThat(api.getOpenApiCustomizers()).hasSizeGreaterThanOrEqualTo(2);
}
@Test
@DisplayName("info customizer sets the processing title and description")
void infoCustomizerSetsTitle() {
GroupedOpenApi api = config.pdfProcessingApi(openApi -> {});
OpenAPI openApi = applyCustomizers(api);
assertThat(openApi.getInfo().getTitle()).isEqualTo("Stirling PDF - Processing API");
assertThat(openApi.getInfo().getDescription()).contains("PDF");
}
}
@Nested
@DisplayName("adminApi")
class AdminApi {
@Test
@DisplayName("builds the management group with admin/user/auth paths")
void buildsGroup() {
GroupedOpenApi api = config.adminApi();
assertThat(api.getGroup()).isEqualTo("management");
assertThat(api.getDisplayName()).isEqualTo("Management");
assertThat(api.getPathsToMatch())
.contains("/api/v1/admin/**", "/api/v1/user/**", "/api/v1/auth/**");
}
@Test
@DisplayName("info customizer sets the management title")
void infoCustomizerSetsTitle() {
GroupedOpenApi api = config.adminApi();
OpenAPI openApi = applyCustomizers(api);
assertThat(openApi.getInfo().getTitle()).isEqualTo("Stirling PDF - Management API");
assertThat(openApi.getInfo().getDescription()).isNotBlank();
}
}
@Nested
@DisplayName("systemApi")
class SystemApi {
@Test
@DisplayName("builds the system group with ui-data/info paths")
void buildsGroup() {
GroupedOpenApi api = config.systemApi();
assertThat(api.getGroup()).isEqualTo("system");
assertThat(api.getDisplayName()).isEqualTo("System & UI API");
assertThat(api.getPathsToMatch())
.contains("/api/v1/ui-data/**", "/api/v1/info/**", "/api/v1/general/job/**");
}
@Test
@DisplayName("info customizer sets the system title")
void infoCustomizerSetsTitle() {
GroupedOpenApi api = config.systemApi();
OpenAPI openApi = applyCustomizers(api);
assertThat(openApi.getInfo().getTitle()).isEqualTo("Stirling PDF - System API");
assertThat(openApi.getInfo().getDescription()).isNotBlank();
}
}
}
@@ -0,0 +1,28 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.LocalDateTime;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.context.event.ContextRefreshedEvent;
class StartupApplicationListenerTest {
@Test
@DisplayName("onApplicationEvent records a startup time")
void recordsStartTime() {
StartupApplicationListener listener = new StartupApplicationListener();
LocalDateTime before = LocalDateTime.now().minusSeconds(1);
listener.onApplicationEvent(new ContextRefreshedEvent(new EmptyContext()));
assertThat(StartupApplicationListener.startTime).isNotNull();
assertThat(StartupApplicationListener.startTime).isAfterOrEqualTo(before);
}
// Minimal ApplicationContext to satisfy ContextRefreshedEvent construction.
private static class EmptyContext
extends org.springframework.context.support.StaticApplicationContext {}
}
@@ -0,0 +1,283 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Optional;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;
@DisplayName("TauriProcessMonitor")
class TauriProcessMonitorTest {
private static Object invokePrivate(TauriProcessMonitor monitor, String name, Object... args)
throws Exception {
Method method = findMethod(name);
method.setAccessible(true);
return method.invoke(monitor, args);
}
private static Method findMethod(String name) {
for (Method m : TauriProcessMonitor.class.getDeclaredMethods()) {
if (m.getName().equals(name)) {
return m;
}
}
throw new IllegalStateException("Method not found: " + name);
}
private static void setField(TauriProcessMonitor monitor, String name, Object value)
throws Exception {
Field field = TauriProcessMonitor.class.getDeclaredField(name);
field.setAccessible(true);
field.set(monitor, value);
}
private static Object getField(TauriProcessMonitor monitor, String name) throws Exception {
Field field = TauriProcessMonitor.class.getDeclaredField(name);
field.setAccessible(true);
return field.get(monitor);
}
@Nested
@DisplayName("getCurrentProcessId")
class CurrentProcessId {
@Test
@DisplayName("returns a non-blank PID string")
void returnsPid() {
assertThat(TauriProcessMonitor.getCurrentProcessId()).isNotBlank();
}
}
@Nested
@DisplayName("init")
class Init {
// System.getenv cannot be mocked (java.base), so init() is exercised against the real
// environment, which has no TAURI_PARENT_PID; the present-PID path is driven directly
// through startMonitoring().
@Test
@DisplayName("startMonitoring flips monitoring on and creates a scheduler")
void startMonitoringSchedulesTask() throws Exception {
ApplicationContext ctx = mock(ApplicationContext.class);
TauriProcessMonitor monitor = new TauriProcessMonitor(ctx);
setField(monitor, "parentProcessId", "12345");
try {
invokePrivate(monitor, "startMonitoring");
assertThat((Boolean) getField(monitor, "monitoring")).isTrue();
assertThat(getField(monitor, "scheduler")).isNotNull();
assertThat((String) getField(monitor, "parentProcessId")).isEqualTo("12345");
} finally {
// Stop the scheduler thread created by startMonitoring.
monitor.cleanup();
}
}
}
@Nested
@DisplayName("isProcessAlive")
class IsProcessAlive {
@Test
@DisplayName("returns true when ProcessHandle reports the PID present")
void aliveWhenPresent() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
try (MockedStatic<ProcessHandle> ph = mockStatic(ProcessHandle.class)) {
ph.when(() -> ProcessHandle.of(999L))
.thenReturn(Optional.of(mock(ProcessHandle.class)));
Object result = invokePrivate(monitor, "isProcessAlive", "999");
assertThat((Boolean) result).isTrue();
}
}
@Test
@DisplayName("returns false when ProcessHandle reports the PID absent")
void deadWhenAbsent() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
try (MockedStatic<ProcessHandle> ph = mockStatic(ProcessHandle.class)) {
ph.when(() -> ProcessHandle.of(999L)).thenReturn(Optional.empty());
Object result = invokePrivate(monitor, "isProcessAlive", "999");
assertThat((Boolean) result).isFalse();
}
}
@Test
@DisplayName("returns false for a non-numeric PID")
void falseForInvalidPid() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
Object result = invokePrivate(monitor, "isProcessAlive", "not-a-number");
assertThat((Boolean) result).isFalse();
}
}
@Nested
@DisplayName("checkParentProcess")
class CheckParentProcess {
@Test
@DisplayName("returns early when monitoring is off")
void earlyReturnWhenNotMonitoring() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
setField(monitor, "monitoring", false);
// Should not throw even though parentProcessId is null.
invokePrivate(monitor, "checkParentProcess");
}
@Test
@DisplayName("triggers graceful shutdown when the parent process is dead")
void shutsDownWhenParentDead() throws Exception {
ConfigurableApplicationContext ctx = mock(ConfigurableApplicationContext.class);
TauriProcessMonitor monitor = new TauriProcessMonitor(ctx);
setField(monitor, "monitoring", true);
setField(monitor, "parentProcessId", "999");
try (MockedStatic<ProcessHandle> ph = mockStatic(ProcessHandle.class)) {
ph.when(() -> ProcessHandle.of(999L)).thenReturn(Optional.empty());
invokePrivate(monitor, "checkParentProcess");
// initiateGracefulShutdown flips monitoring off and spawns an async close.
// The async close runs after a hardcoded 1s sleep, so we assert only the
// immediate, deterministic effect to keep the test fast.
assertThat((Boolean) getField(monitor, "monitoring")).isFalse();
}
}
@Test
@DisplayName("does nothing when the parent process is still alive")
void noShutdownWhenParentAlive() throws Exception {
ConfigurableApplicationContext ctx = mock(ConfigurableApplicationContext.class);
TauriProcessMonitor monitor = new TauriProcessMonitor(ctx);
setField(monitor, "monitoring", true);
setField(monitor, "parentProcessId", "999");
try (MockedStatic<ProcessHandle> ph = mockStatic(ProcessHandle.class)) {
ph.when(() -> ProcessHandle.of(999L))
.thenReturn(Optional.of(mock(ProcessHandle.class)));
invokePrivate(monitor, "checkParentProcess");
assertThat((Boolean) getField(monitor, "monitoring")).isTrue();
}
verify(ctx, never()).close();
}
}
@Nested
@DisplayName("initiateGracefulShutdown")
class InitiateGracefulShutdown {
@Test
@DisplayName("closes a ConfigurableApplicationContext asynchronously")
void closesConfigurableContext() throws Exception {
ConfigurableApplicationContext ctx = mock(ConfigurableApplicationContext.class);
TauriProcessMonitor monitor = new TauriProcessMonitor(ctx);
setField(monitor, "monitoring", true);
invokePrivate(monitor, "initiateGracefulShutdown");
// The async close runs after a hardcoded 1s sleep; assert only the immediate effect.
assertThat((Boolean) getField(monitor, "monitoring")).isFalse();
}
}
@Nested
@DisplayName("cleanup")
class Cleanup {
@Test
@DisplayName("is a no-op when no scheduler was created")
void noOpWithoutScheduler() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
// scheduler is null by default; cleanup must not throw.
monitor.cleanup();
assertThat((Boolean) getField(monitor, "monitoring")).isFalse();
}
@Test
@DisplayName("shuts down an active scheduler")
void shutsDownActiveScheduler() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
when(scheduler.isShutdown()).thenReturn(false);
when(scheduler.awaitTermination(eq(2L), eq(TimeUnit.SECONDS))).thenReturn(true);
setField(monitor, "scheduler", scheduler);
setField(monitor, "monitoring", true);
monitor.cleanup();
verify(scheduler, times(1)).shutdown();
assertThat((Boolean) getField(monitor, "monitoring")).isFalse();
}
@Test
@DisplayName("forces shutdownNow when awaitTermination times out")
void forcesShutdownNowOnTimeout() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
when(scheduler.isShutdown()).thenReturn(false);
when(scheduler.awaitTermination(eq(2L), eq(TimeUnit.SECONDS))).thenReturn(false);
setField(monitor, "scheduler", scheduler);
monitor.cleanup();
verify(scheduler, times(1)).shutdown();
verify(scheduler, times(1)).shutdownNow();
}
@Test
@DisplayName("restores interrupt flag when awaitTermination is interrupted")
void handlesInterruptedException() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
when(scheduler.isShutdown()).thenReturn(false);
when(scheduler.awaitTermination(eq(2L), eq(TimeUnit.SECONDS)))
.thenThrow(new InterruptedException("boom"));
setField(monitor, "scheduler", scheduler);
monitor.cleanup();
verify(scheduler, times(1)).shutdownNow();
// Clear the interrupt flag we just set so it does not leak to other tests.
assertThat(Thread.interrupted()).isTrue();
}
@Test
@DisplayName("skips shutdown when scheduler already terminated")
void skipsAlreadyShutdownScheduler() throws Exception {
TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class));
ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
when(scheduler.isShutdown()).thenReturn(true);
setField(monitor, "scheduler", scheduler);
monitor.cleanup();
verify(scheduler, never()).shutdown();
}
}
}
@@ -0,0 +1,91 @@
package stirling.software.SPDF.config;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.SPDF.service.WeeklyActiveUsersService;
class WAUTrackingFilterTest {
private WeeklyActiveUsersService wauService;
private WAUTrackingFilter filter;
private jakarta.servlet.ServletResponse response;
private FilterChain chain;
@BeforeEach
void setUp() {
wauService = mock(WeeklyActiveUsersService.class);
filter = new WAUTrackingFilter(wauService);
response = mock(jakarta.servlet.ServletResponse.class);
chain = mock(FilterChain.class);
}
@Nested
@DisplayName("browser id handling")
class BrowserId {
@Test
@DisplayName("records access when header present")
void recordsWhenPresent() throws Exception {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("X-Browser-Id")).thenReturn("browser-42");
filter.doFilter(request, response, chain);
verify(wauService).recordBrowserAccess("browser-42");
verify(chain).doFilter(request, response);
}
@Test
@DisplayName("does not record when header is null")
void skipsWhenNull() throws Exception {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("X-Browser-Id")).thenReturn(null);
filter.doFilter(request, response, chain);
verify(wauService, never()).recordBrowserAccess(anyString());
verify(chain).doFilter(request, response);
}
@Test
@DisplayName("does not record when header is blank")
void skipsWhenBlank() throws Exception {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("X-Browser-Id")).thenReturn(" ");
filter.doFilter(request, response, chain);
verify(wauService, never()).recordBrowserAccess(anyString());
verify(chain).doFilter(request, response);
}
}
@Nested
@DisplayName("non-http requests")
class NonHttp {
@Test
@DisplayName("passes through without recording")
void nonHttpPassThrough() throws Exception {
ServletRequest request = mock(ServletRequest.class);
filter.doFilter(request, response, chain);
verify(wauService, never()).recordBrowserAccess(anyString());
verify(chain).doFilter(request, response);
}
}
}
@@ -0,0 +1,202 @@
package stirling.software.SPDF.config;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.web.servlet.config.annotation.CorsRegistration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import stirling.software.common.model.ApplicationProperties;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("WebMvcConfig")
class WebMvcConfigTest {
private static final String TAURI_PROP = "STIRLING_PDF_TAURI_MODE";
@Mock private EndpointInterceptor endpointInterceptor;
@Mock private PdfMetricsInterceptor pdfMetricsInterceptor;
@Mock private ApplicationProperties applicationProperties;
@Mock private ApplicationProperties.System system;
private WebMvcConfig config;
private String originalTauriProp;
@BeforeEach
void setUp() {
originalTauriProp = System.getProperty(TAURI_PROP);
System.clearProperty(TAURI_PROP);
config =
new WebMvcConfig(endpointInterceptor, pdfMetricsInterceptor, applicationProperties);
}
@AfterEach
void tearDown() {
if (originalTauriProp == null) {
System.clearProperty(TAURI_PROP);
} else {
System.setProperty(TAURI_PROP, originalTauriProp);
}
}
@Nested
@DisplayName("addInterceptors")
class AddInterceptors {
@Test
@DisplayName("registers both interceptors in order")
void registersBothInterceptors() {
InterceptorRegistry registry = mock(InterceptorRegistry.class);
InterceptorRegistration registration = mock(InterceptorRegistration.class);
when(registry.addInterceptor(any())).thenReturn(registration);
config.addInterceptors(registry);
verify(registry).addInterceptor(endpointInterceptor);
verify(registry).addInterceptor(pdfMetricsInterceptor);
}
}
@Nested
@DisplayName("addResourceHandlers")
class AddResourceHandlers {
@Test
@DisplayName("registers all five resource handler groups")
void registersFiveHandlerGroups() {
ResourceHandlerRegistry registry = mock(ResourceHandlerRegistry.class);
ResourceHandlerRegistration registration =
mock(ResourceHandlerRegistration.class, RETURNS_DEEP_STUBS);
when(registry.addResourceHandler(any(String[].class))).thenReturn(registration);
config.addResourceHandlers(registry);
// SW/PWA, assets, media+fonts, branding, catch-all = 5 handler registrations.
verify(registry, times(5)).addResourceHandler(any(String[].class));
}
@Test
@DisplayName("includes the SPA catch-all and assets patterns")
void includesKnownPatterns() {
ResourceHandlerRegistry registry = mock(ResourceHandlerRegistry.class);
ResourceHandlerRegistration registration =
mock(ResourceHandlerRegistration.class, RETURNS_DEEP_STUBS);
when(registry.addResourceHandler(any(String[].class))).thenReturn(registration);
config.addResourceHandlers(registry);
ArgumentCaptor<String[]> captor = ArgumentCaptor.forClass(String[].class);
verify(registry, atLeastOnce()).addResourceHandler(captor.capture());
List<String> allPatterns =
captor.getAllValues().stream().flatMap(java.util.Arrays::stream).toList();
assertThat(allPatterns).contains("/**", "/assets/**", "/sw.js");
}
}
@Nested
@DisplayName("addCorsMappings")
class AddCorsMappings {
private CorsRegistry registry;
private CorsRegistration registration;
@BeforeEach
void initRegistry() {
registry = mock(CorsRegistry.class);
registration = mock(CorsRegistration.class, RETURNS_DEEP_STUBS);
when(registry.addMapping(anyString())).thenReturn(registration);
}
@Test
@DisplayName("Tauri mode adds a mapping with Tauri origin patterns")
void tauriModeBranch() {
System.setProperty(TAURI_PROP, "true");
// hasConfiguredOrigins is evaluated before the Tauri check, so getSystem() is
// consulted.
when(applicationProperties.getSystem()).thenReturn(system);
when(system.getCorsAllowedOrigins()).thenReturn(List.of());
config.addCorsMappings(registry);
verify(registry).addMapping("/**");
}
@Test
@DisplayName("uses configured origins and appends Tauri origins when present")
void configuredOriginsBranch() {
when(applicationProperties.getSystem()).thenReturn(system);
when(system.getCorsAllowedOrigins())
.thenReturn(new java.util.ArrayList<>(List.of("https://app.example.com")));
config.addCorsMappings(registry);
verify(registry).addMapping("/**");
// origins consulted twice (presence check + value use)
verify(system, atLeastOnce()).getCorsAllowedOrigins();
}
@Test
@DisplayName("configured origins keep an already-present Tauri origin unduplicated")
void configuredOriginsAlreadyContainTauri() {
when(applicationProperties.getSystem()).thenReturn(system);
when(system.getCorsAllowedOrigins())
.thenReturn(
new java.util.ArrayList<>(
List.of(
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost")));
config.addCorsMappings(registry);
verify(registry).addMapping("/**");
}
@Test
@DisplayName("default branch allows all origins when nothing configured")
void defaultBranchAllowsAll() {
when(applicationProperties.getSystem()).thenReturn(system);
when(system.getCorsAllowedOrigins()).thenReturn(List.of());
config.addCorsMappings(registry);
verify(registry).addMapping("/**");
}
@Test
@DisplayName("default branch also triggers when system is null")
void defaultBranchWhenSystemNull() {
lenient().when(applicationProperties.getSystem()).thenReturn(null);
config.addCorsMappings(registry);
verify(registry).addMapping("/**");
}
}
}
@@ -0,0 +1,225 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.general.CropPdfForm;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
/**
* Additional branch coverage for {@link CropController}: the Ghostscript routing decision (enabled
* vs disabled when removeDataOutsideCrop is set), the Ghostscript execution path with the external
* process mocked, and the large-image sampling-step branch of detectContentBounds. The gs binary is
* never invoked.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("CropController additional branch tests")
class CropControllerMoreTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private EndpointConfiguration endpointConfiguration;
@InjectMocks private CropController cropController;
@BeforeEach
void setUp() throws Exception {
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile(
tempDir, "crop", inv.<String>getArgument(0))
.toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
lenient().when(tf.getAbsolutePath()).thenReturn(f.getAbsolutePath());
return tf;
});
}
private MockMultipartFile pdf(int pages) throws IOException {
Path p = tempDir.resolve("crop-src.pdf");
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.LETTER));
}
doc.save(p.toFile());
}
return new MockMultipartFile(
"fileInput", "src.pdf", MediaType.APPLICATION_PDF_VALUE, Files.readAllBytes(p));
}
private CropPdfForm form(MockMultipartFile file, boolean removeOutside) {
CropPdfForm f = new CropPdfForm();
f.setFileInput(file);
f.setX(20f);
f.setY(20f);
f.setWidth(200f);
f.setHeight(300f);
f.setAutoCrop(false);
f.setRemoveDataOutsideCrop(removeOutside);
return f;
}
@Nested
@DisplayName("Ghostscript routing")
class GhostscriptRouting {
@Test
@DisplayName(
"removeDataOutsideCrop with Ghostscript disabled falls back to the PDFBox path")
void disabledFallsBackToPdfBox() throws Exception {
MockMultipartFile file = pdf(1);
CropPdfForm request = form(file, true);
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
PDDocument source = mock(PDDocument.class);
PDDocument out = mock(PDDocument.class);
when(pdfDocumentFactory.load(request)).thenReturn(source);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(source)).thenReturn(out);
ResponseEntity<Resource> response = cropController.cropPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// PDFBox path constructs a new document; gs path would not.
verify(pdfDocumentFactory).createNewDocumentBasedOnOldDocument(source);
verify(source).close();
verify(out).close();
}
@Test
@DisplayName("removeDataOutsideCrop with Ghostscript enabled runs the gs command path")
void enabledRunsGhostscript() throws Exception {
MockMultipartFile file = pdf(2);
CropPdfForm request = form(file, true);
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
// Real document so setCropBox + save() succeed inside the gs branch.
when(pdfDocumentFactory.load(request)).thenReturn(Loader.loadPDF(file.getBytes()));
ProcessExecutor executor = mock(ProcessExecutor.class);
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT))
.thenReturn(executor);
when(executor.runCommandWithOutputHandling(any())).thenReturn(null);
ResponseEntity<Resource> response = cropController.cropPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
verify(executor).runCommandWithOutputHandling(any());
}
}
@Test
@DisplayName("Ghostscript interruption is wrapped and surfaced")
void interruptedIsWrapped() throws Exception {
MockMultipartFile file = pdf(1);
CropPdfForm request = form(file, true);
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
when(pdfDocumentFactory.load(request)).thenReturn(Loader.loadPDF(file.getBytes()));
ProcessExecutor executor = mock(ProcessExecutor.class);
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT))
.thenReturn(executor);
when(executor.runCommandWithOutputHandling(any()))
.thenThrow(new InterruptedException("stop"));
org.junit.jupiter.api.Assertions.assertThrows(
Exception.class, () -> cropController.cropPdf(request));
}
}
}
@Nested
@DisplayName("detectContentBounds sampling step")
class SamplingStep {
private Method detect;
@BeforeEach
void setUp() throws Exception {
detect =
CropController.class.getDeclaredMethod(
"detectContentBounds", BufferedImage.class);
detect.setAccessible(true);
}
@Test
@DisplayName("large images (>2000px) use a step of 2 and still locate content")
void largeImageUsesStep2() throws Exception {
// Width > 2000 triggers the step=2 sampling branch.
BufferedImage image = new BufferedImage(2100, 50, BufferedImage.TYPE_INT_RGB);
for (int x = 0; x < 2100; x++) {
for (int y = 0; y < 50; y++) {
image.setRGB(x, y, 0xFFFFFF);
}
}
// Dark block on even coordinates so the step-2 scan can see it.
for (int x = 1000; x < 1040; x += 2) {
for (int y = 10; y < 30; y += 2) {
image.setRGB(x, y, 0x000000);
}
}
int[] bounds = (int[]) detect.invoke(null, image);
assertThat(bounds).hasSize(4);
assertThat(bounds[0]).isGreaterThanOrEqualTo(0);
assertThat(bounds[2]).isGreaterThan(bounds[0]);
}
@Test
@DisplayName("zero-size image returns degenerate bounds")
void zeroSizeImage() throws Exception {
BufferedImage image = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB);
image.setRGB(0, 0, 0xFFFFFF);
int[] bounds = (int[]) detect.invoke(null, image);
assertThat(bounds).containsExactly(0, 0, 0, 0);
}
}
}
@@ -0,0 +1,173 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
/**
* Extra coverage for {@link MergeController} helpers that the mock-based and end-to-end suites do
* not reach directly: the PDFBox {@code addTableOfContents} outline builder and {@code
* mergeDocuments} page-copy path, both driven over real in-memory documents.
*/
class MergeControllerExtraTest {
private CustomPDFDocumentFactory pdfDocumentFactory;
private MergeController mergeController;
@BeforeEach
void setUp() {
pdfDocumentFactory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
TempFileManager tempFileManager =
new TempFileManager(new TempFileRegistry(), new ApplicationProperties());
mergeController = new MergeController(pdfDocumentFactory, tempFileManager);
}
private static byte[] pdfBytes(int pages) throws IOException {
try (PDDocument doc = new PDDocument();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
doc.save(baos);
return baos.toByteArray();
}
}
private static MockMultipartFile pdf(String name, int pages) throws IOException {
return new MockMultipartFile(
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, pdfBytes(pages));
}
private void addTableOfContents(PDDocument merged, MultipartFile[] files) throws Exception {
Method m =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
m.setAccessible(true);
m.invoke(mergeController, merged, files);
}
@Nested
@DisplayName("mergeDocuments")
class MergeDocuments {
@Test
@DisplayName("copies all pages from every source into a single document")
void copiesAllPages() throws Exception {
try (PDDocument a = loadPdf(pdfBytes(2));
PDDocument b = loadPdf(pdfBytes(3))) {
try (PDDocument merged = mergeController.mergeDocuments(List.of(a, b))) {
assertThat(merged.getNumberOfPages()).isEqualTo(5);
}
}
}
@Test
@DisplayName("an empty source list yields an empty merged document")
void emptyListEmptyDoc() throws Exception {
try (PDDocument merged = mergeController.mergeDocuments(List.of())) {
assertThat(merged.getNumberOfPages()).isZero();
}
}
@Test
@DisplayName("a single source is copied verbatim")
void singleSource() throws Exception {
try (PDDocument only = loadPdf(pdfBytes(4))) {
try (PDDocument merged = mergeController.mergeDocuments(List.of(only))) {
assertThat(merged.getNumberOfPages()).isEqualTo(4);
}
}
}
private PDDocument loadPdf(byte[] bytes) throws IOException {
return org.apache.pdfbox.Loader.loadPDF(bytes);
}
}
@Nested
@DisplayName("addTableOfContents")
class AddTableOfContents {
@Test
@DisplayName("adds one outline entry per input file titled by filename without extension")
void oneEntryPerFile() throws Exception {
MultipartFile[] files = {pdf("intro.pdf", 1), pdf("body.pdf", 2)};
try (PDDocument merged = new PDDocument()) {
for (int i = 0; i < 3; i++) {
merged.addPage(new PDPage(PDRectangle.A4));
}
addTableOfContents(merged, files);
PDDocumentOutline outline = merged.getDocumentCatalog().getDocumentOutline();
assertThat(outline).isNotNull();
List<String> titles = outlineTitles(outline);
assertThat(titles).containsExactly("intro", "body");
}
}
@Test
@DisplayName("outline destinations advance by each source's page count")
void destinationsAdvance() throws Exception {
MultipartFile[] files = {pdf("first.pdf", 1), pdf("second.pdf", 1)};
try (PDDocument merged = new PDDocument()) {
merged.addPage(new PDPage(PDRectangle.A4));
merged.addPage(new PDPage(PDRectangle.A4));
addTableOfContents(merged, files);
PDDocumentOutline outline = merged.getDocumentCatalog().getDocumentOutline();
Iterator<PDOutlineItem> it = outline.children().iterator();
// both items resolve and the outline is non-empty
assertThat(it.hasNext()).isTrue();
}
}
@Test
@DisplayName("handles a single-file table of contents")
void singleFileToc() throws Exception {
MultipartFile[] files = {pdf("solo.pdf", 1)};
try (PDDocument merged = new PDDocument()) {
merged.addPage(new PDPage(PDRectangle.A4));
addTableOfContents(merged, files);
assertThat(outlineTitles(merged.getDocumentCatalog().getDocumentOutline()))
.containsExactly("solo");
}
}
private List<String> outlineTitles(PDDocumentOutline outline) {
List<String> titles = new ArrayList<>();
for (PDOutlineItem item : outline.children()) {
titles.add(item.getTitle());
}
return titles;
}
}
}
@@ -0,0 +1,365 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Calendar;
import java.util.GregorianCalendar;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.general.MergePdfsRequest;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
/**
* End-to-end coverage for {@link MergeController#mergePdfs} using real in-memory PDFs and the real
* JPDFium merge pipeline plus a real {@link CustomPDFDocumentFactory}/{@link TempFileManager}.
* Exercises the sort modes, the fileOrder reordering branch, table-of-contents generation, the
* removeCertSign signature pre-check, single/empty file handling and the corrupted-input path that
* the mock-based {@code MergeControllerTest}/{@code MergeControllerGapTest} do not reach.
*/
class MergeControllerMoreTest {
private CustomPDFDocumentFactory pdfDocumentFactory;
private TempFileManager tempFileManager;
private MergeController mergeController;
@BeforeEach
void setUp() {
pdfDocumentFactory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class));
tempFileManager = new TempFileManager(new TempFileRegistry(), new ApplicationProperties());
mergeController = new MergeController(pdfDocumentFactory, tempFileManager);
}
// ---- helpers ------------------------------------------------------------
private static byte[] buildPdf(int pageCount, String title, Long modMillis) throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
for (int i = 0; i < pageCount; i++) {
PDPage page = new PDPage(PDRectangle.A4);
document.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(document, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(72, 720);
cs.showText("Body " + (i + 1));
cs.endText();
}
}
PDDocumentInformation info = document.getDocumentInformation();
if (title != null) {
info.setTitle(title);
}
if (modMillis != null) {
Calendar cal = new GregorianCalendar();
cal.setTimeInMillis(modMillis);
info.setModificationDate(cal);
}
document.save(baos);
return baos.toByteArray();
}
}
private static MockMultipartFile pdf(String name, int pages) throws IOException {
return new MockMultipartFile(
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, buildPdf(pages, null, null));
}
private static MockMultipartFile pdf(String name, int pages, String title, Long modMillis)
throws IOException {
return new MockMultipartFile(
"fileInput",
name,
MediaType.APPLICATION_PDF_VALUE,
buildPdf(pages, title, modMillis));
}
private static MergePdfsRequest request(
MockMultipartFile[] files, String sortType, boolean removeCertSign, boolean toc) {
MergePdfsRequest req = new MergePdfsRequest();
req.setFileInput(files);
req.setSortType(sortType);
req.setRemoveCertSign(removeCertSign);
req.setGenerateToc(toc);
return req;
}
private static PDDocument readResponse(ResponseEntity<Resource> response) throws IOException {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
byte[] out;
try (InputStream is = response.getBody().getInputStream()) {
out = is.readAllBytes();
}
assertThat(out.length).isGreaterThan(0);
return Loader.loadPDF(out);
}
@Nested
@DisplayName("Basic merge")
class BasicMerge {
@Test
@DisplayName("merges two PDFs and sums the page counts")
void mergesTwoFiles() throws Exception {
MockMultipartFile[] files = {pdf("a.pdf", 2), pdf("b.pdf", 3)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "orderProvided", false, false), null);
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(5);
}
}
@Test
@DisplayName("merges three PDFs preserving total pages")
void mergesThreeFiles() throws Exception {
MockMultipartFile[] files = {pdf("a.pdf", 1), pdf("b.pdf", 2), pdf("c.pdf", 1)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "orderProvided", false, false), null);
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(4);
}
}
@Test
@DisplayName("merging a single file returns its pages")
void mergesSingleFile() throws Exception {
MockMultipartFile[] files = {pdf("solo.pdf", 4)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "orderProvided", false, false), null);
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(4);
}
}
@Test
@DisplayName("null fileInput is treated as an empty set and still returns OK")
void nullFileInput() throws Exception {
MergePdfsRequest req = new MergePdfsRequest();
req.setFileInput(null);
req.setSortType("orderProvided");
req.setRemoveCertSign(false);
ResponseEntity<Resource> response = mergeController.mergePdfs(req, null);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
@Test
@DisplayName("empty file array returns an empty (zero-byte) body")
void emptyFileArray() throws Exception {
ResponseEntity<Resource> response =
mergeController.mergePdfs(
request(new MockMultipartFile[0], "orderProvided", false, false), null);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
@Nested
@DisplayName("Sort modes")
class SortModes {
@ParameterizedTest
@ValueSource(
strings = {
"orderProvided",
"byFileName",
"byDateModified",
"byDateCreated",
"byPDFTitle",
"unknownSortType"
})
@DisplayName("every sort mode produces a valid merged document")
void allSortModes(String sortType) throws Exception {
MockMultipartFile[] files = {
pdf("charlie.pdf", 1, "Gamma", 3_000L),
pdf("alpha.pdf", 1, "Alpha", 1_000L),
pdf("bravo.pdf", 1, "Beta", 2_000L)
};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, sortType, false, false), null);
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(3);
}
}
@Test
@DisplayName("byFileName orders the first output filename deterministically")
void byFileNameUsesFirstAlphabetical() throws Exception {
MockMultipartFile[] files = {pdf("zebra.pdf", 1), pdf("apple.pdf", 1)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "byFileName", false, false), null);
String disposition =
response.getHeaders()
.getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION);
// apple.pdf sorts first so it seeds the generated merged filename
assertThat(disposition).contains("apple");
}
}
@Nested
@DisplayName("fileOrder reordering")
class FileOrder {
@Test
@DisplayName("fileOrder param overrides sortType and drives the merge order")
void fileOrderOverridesSort() throws Exception {
MockMultipartFile[] files = {pdf("first.pdf", 1), pdf("second.pdf", 2)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(
request(files, "byFileName", false, false), "second.pdf\nfirst.pdf");
String disposition =
response.getHeaders()
.getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION);
assertThat(disposition).contains("second");
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(3);
}
}
@Test
@DisplayName("blank fileOrder falls through to the sortType branch")
void blankFileOrderUsesSort() throws Exception {
MockMultipartFile[] files = {pdf("a.pdf", 1), pdf("b.pdf", 1)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "orderProvided", false, false), " ");
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(2);
}
}
}
@Nested
@DisplayName("Table of contents and bookmarks")
class TableOfContents {
@Test
@DisplayName("generateToc adds a document outline keyed by filename")
void generatesToc() throws Exception {
MockMultipartFile[] files = {pdf("intro.pdf", 1), pdf("body.pdf", 2)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "orderProvided", false, true), null);
try (PDDocument result = readResponse(response)) {
PDDocumentOutline outline = result.getDocumentCatalog().getDocumentOutline();
assertThat(outline).isNotNull();
assertThat(outline.children().iterator().hasNext()).isTrue();
}
}
@Test
@DisplayName("a blank filename falls back to a generated Document N title")
void tocBlankFilenameFallback() throws Exception {
MockMultipartFile blankName =
new MockMultipartFile(
"fileInput",
"",
MediaType.APPLICATION_PDF_VALUE,
buildPdf(1, null, null));
MockMultipartFile[] files = {blankName, pdf("named.pdf", 1)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "orderProvided", false, true), null);
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(2);
}
}
}
@Nested
@DisplayName("removeCertSign branch")
class RemoveCertSign {
@Test
@DisplayName("removeCertSign with no signatures skips the flatten pass but still merges")
void removeCertSignNoSignatures() throws Exception {
MockMultipartFile[] files = {pdf("a.pdf", 1), pdf("b.pdf", 1)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "orderProvided", true, false), null);
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(2);
}
}
@Test
@DisplayName("removeCertSign=false copies the merged output directly")
void removeCertSignFalse() throws Exception {
MockMultipartFile[] files = {pdf("a.pdf", 2), pdf("b.pdf", 1)};
ResponseEntity<Resource> response =
mergeController.mergePdfs(request(files, "orderProvided", false, false), null);
try (PDDocument result = readResponse(response)) {
assertThat(result.getNumberOfPages()).isEqualTo(3);
}
}
}
@Nested
@DisplayName("Corrupted input handling")
class CorruptedInput {
// Drives the PDF pre-validate loop and the merge error path. JPDFium may either reject the
// garbage payload (throw) or salvage a degenerate document; both outcomes are acceptable,
// so
// we only assert the code path runs and any thrown error is an Exception (logged +
// rethrown).
@Test
@DisplayName("a non-PDF payload exercises the pre-validate and merge error branch")
void corruptedPayloadHandled() throws Exception {
MockMultipartFile good = pdf("good.pdf", 1);
MockMultipartFile bad =
new MockMultipartFile(
"fileInput",
"broken.pdf",
MediaType.APPLICATION_PDF_VALUE,
"this is not a pdf at all".getBytes());
MergePdfsRequest req =
request(new MockMultipartFile[] {good, bad}, "orderProvided", false, false);
try {
ResponseEntity<Resource> response = mergeController.mergePdfs(req, null);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
} catch (Exception expected) {
assertThat(expected).isInstanceOf(Exception.class);
}
}
@Test
@DisplayName("an entirely empty payload still runs through the merge pipeline")
void emptyPayloadHandled() throws Exception {
MockMultipartFile empty =
new MockMultipartFile(
"fileInput", "empty.pdf", MediaType.APPLICATION_PDF_VALUE, new byte[0]);
MergePdfsRequest req =
request(new MockMultipartFile[] {empty}, "orderProvided", false, false);
try {
ResponseEntity<Resource> response = mergeController.mergePdfs(req, null);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
} catch (Exception expected) {
assertThat(expected).isInstanceOf(Exception.class);
}
}
}
}
@@ -0,0 +1,260 @@
package stirling.software.SPDF.controller.api;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.File;
import java.nio.file.Files;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
/**
* Branch coverage for {@link MultiPageLayoutController#mergeMultiplePagesIntoOne} options:
* orientation/arrangement/reading-direction validation, margin validation, landscape, RTL and
* by-column layouts, all exercised against real multi-page source documents.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("MultiPageLayoutController options")
class MultiPageLayoutControllerMoreTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
private MultiPageLayoutController controller;
@BeforeEach
void setUp() throws Exception {
controller = new MultiPageLayoutController(pdfDocumentFactory, tempFileManager);
when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("mpl", inv.<String>getArgument(0))
.toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
}
/** Wires the factory to load real source pages and return a real target document. */
private void wireDocuments(int pages) throws Exception {
PDDocument source = new PDDocument();
for (int i = 0; i < pages; i++) {
source.addPage(new PDPage(PDRectangle.A4));
}
when(pdfDocumentFactory.load(any(org.springframework.web.multipart.MultipartFile.class)))
.thenReturn(source);
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(source))
.thenReturn(new PDDocument());
}
private static MockMultipartFile file() {
return new MockMultipartFile("fileInput", "in.pdf", "application/pdf", new byte[] {1});
}
private static MergeMultiplePagesRequest base() {
MergeMultiplePagesRequest req = new MergeMultiplePagesRequest();
req.setFileInput(file());
req.setPagesPerSheet(4);
return req;
}
@Nested
@DisplayName("validation failures")
class Validation {
@Test
@DisplayName("unknown mode is rejected")
void unknownModeThrows() {
MergeMultiplePagesRequest req = base();
req.setMode("WEIRD");
assertThrows(
IllegalArgumentException.class,
() -> controller.mergeMultiplePagesIntoOne(req));
}
@Test
@DisplayName("custom mode with non-positive rows/cols is rejected")
void customNonPositiveThrows() {
MergeMultiplePagesRequest req = base();
req.setMode("CUSTOM");
req.setRows(0);
req.setCols(2);
assertThrows(
IllegalArgumentException.class,
() -> controller.mergeMultiplePagesIntoOne(req));
}
@Test
@DisplayName("invalid orientation is rejected")
void invalidOrientationThrows() {
MergeMultiplePagesRequest req = base();
req.setOrientation("DIAGONAL");
assertThrows(
IllegalArgumentException.class,
() -> controller.mergeMultiplePagesIntoOne(req));
}
@Test
@DisplayName("invalid arrangement is rejected")
void invalidArrangementThrows() {
MergeMultiplePagesRequest req = base();
req.setArrangement("SPIRAL");
assertThrows(
IllegalArgumentException.class,
() -> controller.mergeMultiplePagesIntoOne(req));
}
@Test
@DisplayName("invalid reading direction is rejected")
void invalidReadingDirectionThrows() {
MergeMultiplePagesRequest req = base();
req.setReadingDirection("DIAGONAL");
assertThrows(
IllegalArgumentException.class,
() -> controller.mergeMultiplePagesIntoOne(req));
}
@Test
@DisplayName("negative margins are rejected")
void negativeMarginsThrows() {
MergeMultiplePagesRequest req = base();
req.setTopMargin(-1);
assertThrows(
IllegalArgumentException.class,
() -> controller.mergeMultiplePagesIntoOne(req));
}
@Test
@DisplayName("outer margins that consume the whole page yield a non-positive cell error")
void outerMarginsTooLargeThrows() throws Exception {
wireDocuments(1);
MergeMultiplePagesRequest req = base();
// A4 width is ~595pt; 600 left margin alone makes cell width non-positive.
req.setLeftMargin(600);
req.setRightMargin(600);
assertThrows(
IllegalArgumentException.class,
() -> controller.mergeMultiplePagesIntoOne(req));
}
@Test
@DisplayName("inner margin larger than the cell yields a non-positive inner-area error")
void innerMarginTooLargeThrows() throws Exception {
wireDocuments(1);
MergeMultiplePagesRequest req = base();
req.setInnerMargin(1000);
assertThrows(
IllegalArgumentException.class,
() -> controller.mergeMultiplePagesIntoOne(req));
}
}
@Nested
@DisplayName("layout option branches")
class LayoutOptions {
@Test
@DisplayName("landscape orientation succeeds and skips form copying")
void landscapeSucceeds() throws Exception {
wireDocuments(4);
MergeMultiplePagesRequest req = base();
req.setOrientation("LANDSCAPE");
req.setAddBorder(Boolean.TRUE);
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(response.getBody().contentLength() >= 0);
}
@Test
@DisplayName("right-to-left reading direction succeeds")
void rtlSucceeds() throws Exception {
wireDocuments(4);
MergeMultiplePagesRequest req = base();
req.setReadingDirection("RTL");
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
@DisplayName("by-columns arrangement with RTL succeeds")
void byColumnsRtlSucceeds() throws Exception {
wireDocuments(4);
MergeMultiplePagesRequest req = base();
req.setArrangement("BY_COLUMNS");
req.setReadingDirection("RTL");
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
@DisplayName("by-columns arrangement with LTR succeeds")
void byColumnsLtrSucceeds() throws Exception {
wireDocuments(4);
MergeMultiplePagesRequest req = base();
req.setArrangement("BY_COLUMNS");
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
@DisplayName("custom mode with explicit borderWidth succeeds")
void customModeWithBorderSucceeds() throws Exception {
wireDocuments(6);
MergeMultiplePagesRequest req = base();
req.setMode("CUSTOM");
req.setRows(2);
req.setCols(3);
req.setAddBorder(Boolean.TRUE);
req.setBorderWidth(3);
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
@DisplayName("blank mode string defaults to DEFAULT and succeeds")
void blankModeDefaults() throws Exception {
wireDocuments(2);
MergeMultiplePagesRequest req = base();
req.setMode(" ");
req.setPagesPerSheet(2);
ResponseEntity<Resource> response = controller.mergeMultiplePagesIntoOne(req);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
}
@@ -0,0 +1,260 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageFitDestination;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.TempFileManager;
/**
* Additional branch coverage for {@link SplitPdfByChaptersController}: nested-bookmark depth
* limiting, the same-page bookmark merge path, and the AcroForm-bearing branch that routes per
* chapter through PDFBox instead of JPDFium. All PDFs are built in memory.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("SplitPdfByChaptersController additional branch tests")
class SplitPdfByChaptersControllerMoreTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private PdfMetadataService pdfMetadataService;
@Mock private TempFileManager tempFileManager;
@InjectMocks private SplitPdfByChaptersController controller;
@BeforeEach
void setUp() throws IOException {
when(tempFileManager.createTempFile(anyString()))
.thenAnswer(
inv ->
Files.createTempFile(tempDir, "ch", inv.<String>getArgument(0))
.toFile());
lenient()
.when(pdfDocumentFactory.load(any(File.class)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
lenient()
.when(pdfDocumentFactory.load(any(File.class), eq(true)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
}
private PDOutlineItem item(PDDocument doc, String title, int pageIndex) {
PDOutlineItem oi = new PDOutlineItem();
oi.setTitle(title);
PDPageFitDestination dest = new PDPageFitDestination();
dest.setPage(doc.getPage(pageIndex));
oi.setDestination(dest);
return oi;
}
private MockMultipartFile asFile(byte[] bytes) {
return new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, bytes);
}
private SplitPdfByChaptersRequest request(byte[] bytes, int level, boolean dupes) {
SplitPdfByChaptersRequest req = new SplitPdfByChaptersRequest();
req.setFileInput(asFile(bytes));
req.setBookmarkLevel(level);
req.setIncludeMetadata(false);
req.setAllowDuplicates(dupes);
return req;
}
private List<byte[]> unzip(Resource zip) throws IOException {
List<byte[]> out = new ArrayList<>();
try (ZipInputStream zis =
new ZipInputStream(new ByteArrayInputStream(zip.getContentAsByteArray()))) {
ZipEntry e;
while ((e = zis.getNextEntry()) != null) {
out.add(zis.readAllBytes());
zis.closeEntry();
}
}
return out;
}
private int totalPages(List<byte[]> entries) throws IOException {
int total = 0;
for (byte[] data : entries) {
try (PDDocument doc = Loader.loadPDF(data)) {
total += doc.getNumberOfPages();
}
}
return total;
}
@Nested
@DisplayName("Nested bookmarks")
class NestedBookmarks {
/** Builds a doc with top-level chapters, each carrying one child bookmark. */
private byte[] nestedDoc() throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < 8; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
PDDocumentOutline outline = new PDDocumentOutline();
doc.getDocumentCatalog().setDocumentOutline(outline);
PDOutlineItem chapter1 = item(doc, "Chapter 1", 0);
chapter1.addLast(item(doc, "Section 1.1", 2));
outline.addLast(chapter1);
PDOutlineItem chapter2 = item(doc, "Chapter 2", 4);
chapter2.addLast(item(doc, "Section 2.1", 6));
outline.addLast(chapter2);
Path p = tempDir.resolve("nested.pdf");
doc.save(p.toFile());
return Files.readAllBytes(p);
}
}
@Test
@DisplayName("level 0 collects only top-level chapters")
void levelZeroTopLevelOnly() throws Exception {
ResponseEntity<Resource> response = controller.splitPdf(request(nestedDoc(), 0, true));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
// Only the 2 top-level chapters become split points at level 0.
assertThat(outputs).hasSize(2);
assertThat(totalPages(outputs)).isEqualTo(8);
}
@Test
@DisplayName("a deeper level descends into child bookmarks")
void deeperLevelIncludesChildren() throws Exception {
int topLevelCount =
unzip(controller.splitPdf(request(nestedDoc(), 0, true)).getBody()).size();
ResponseEntity<Resource> response = controller.splitPdf(request(nestedDoc(), 2, true));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
// Descending into children yields at least as many split points as the top level.
assertThat(outputs.size()).isGreaterThanOrEqualTo(topLevelCount);
assertThat(totalPages(outputs)).isEqualTo(8);
}
}
@Nested
@DisplayName("Same-page bookmark merge")
class SamePageMerge {
@Test
@DisplayName("bookmarks on the same page are merged when duplicates are disallowed")
void mergesSamePageBookmarks() throws Exception {
byte[] bytes;
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < 4; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
PDDocumentOutline outline = new PDDocumentOutline();
doc.getDocumentCatalog().setDocumentOutline(outline);
// Two bookmarks both pointing at page index 0 -> same start/end -> merged.
outline.addLast(item(doc, "Intro A", 0));
outline.addLast(item(doc, "Intro B", 0));
outline.addLast(item(doc, "Body", 2));
Path p = tempDir.resolve("samepage.pdf");
doc.save(p.toFile());
bytes = Files.readAllBytes(p);
}
ResponseEntity<Resource> response = controller.splitPdf(request(bytes, 0, false));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
// The two same-page intros collapse, leaving fewer outputs than bookmarks.
assertThat(outputs.size()).isLessThan(3);
assertThat(totalPages(outputs)).isGreaterThan(0);
}
}
@Nested
@DisplayName("Form-bearing PDFs route through PDFBox")
class FormBearing {
private byte[] formDocWithBookmarks() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDAcroForm acroForm = new PDAcroForm(doc);
doc.getDocumentCatalog().setAcroForm(acroForm);
for (int i = 0; i < 4; i++) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
PDTextField field = new PDTextField(acroForm);
field.setPartialName("text_p" + (i + 1));
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(new PDRectangle(100, 700, 200, 20));
widget.setPage(page);
field.setWidgets(List.of(widget));
page.getAnnotations().add(widget);
acroForm.getFields().add(field);
}
PDDocumentOutline outline = new PDDocumentOutline();
doc.getDocumentCatalog().setDocumentOutline(outline);
outline.addLast(item(doc, "Chapter 1", 0));
outline.addLast(item(doc, "Chapter 2", 2));
Path p = tempDir.resolve("form.pdf");
doc.save(p.toFile());
return Files.readAllBytes(p);
}
}
@Test
@DisplayName("a PDF with an AcroForm still splits into the expected chapters")
void formPdfSplits() throws Exception {
ResponseEntity<Resource> response =
controller.splitPdf(request(formDocWithBookmarks(), 0, true));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<byte[]> outputs = unzip(response.getBody());
assertThat(outputs).hasSize(2);
assertThat(totalPages(outputs)).isEqualTo(4);
}
}
}
@@ -0,0 +1,206 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Path;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.rendering.ImageType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertToImageRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CheckProgramInstall;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
/**
* Extra branch coverage for {@link ConvertImgPDFController#convertToImage} not exercised by the
* existing tests: the null-result logging branch, the octet-stream media-type fallback, and the
* webp-with-Python path that produces no output files. The Python/ProcessExecutor boundary is
* mocked so no interpreter or external binary ever runs.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ConvertImgPDFController extra convertToImage branches")
class ConvertImgPDFControllerExtraTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private EndpointConfiguration endpointConfiguration;
@InjectMocks private ConvertImgPDFController controller;
private static byte[] tinyPdfBytes(int pages) throws IOException {
try (PDDocument doc = new PDDocument();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
doc.save(baos);
return baos.toByteArray();
}
}
private static PDDocument tinyDocument(int pages) {
PDDocument doc = new PDDocument();
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
return doc;
}
private static MockMultipartFile pdfFile(byte[] bytes) {
return new MockMultipartFile("fileInput", "source.pdf", "application/pdf", bytes);
}
private ConvertToImageRequest baseRequest(byte[] pdf, String format) {
ConvertToImageRequest request = new ConvertToImageRequest();
request.setFileInput(pdfFile(pdf));
request.setImageFormat(format);
request.setSingleOrMultiple("single");
request.setColorType("color");
request.setDpi(72);
request.setPageNumbers("all");
request.setIncludeAnnotations(false);
return request;
}
@Nested
@DisplayName("non-webp branches")
class NonWebp {
@Test
@DisplayName("null render result still produces a single-image response")
void nullResultStillResponds() throws Exception {
byte[] pdfBytes = tinyPdfBytes(1);
ConvertToImageRequest request = baseRequest(pdfBytes, "png");
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
.thenReturn(tinyDocument(1));
@SuppressWarnings("unchecked")
ResponseEntity<byte[]> expected = Mockito.mock(ResponseEntity.class);
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
// Null bytes hit the "resultant bytes is null" log branch but still respond.
pu.when(
() ->
PdfUtils.convertFromPdf(
eq(pdfDocumentFactory),
any(byte[].class),
eq("PNG"),
eq(ImageType.RGB),
eq(true),
eq(72),
any(String.class),
eq(false)))
.thenReturn(null);
wr.when(
() ->
WebResponseUtils.bytesToWebResponse(
any(), any(String.class), any(MediaType.class)))
.thenReturn(expected);
ResponseEntity<?> response = controller.convertToImage(request);
assertThat(response).isSameAs(expected);
}
}
}
@Nested
@DisplayName("webp-with-Python branch")
class WebpWithPython {
@Test
@DisplayName("throws when the Python conversion yields no webp files")
void noWebpFilesProducedThrows() throws Exception {
byte[] pdfBytes = tinyPdfBytes(1);
ConvertToImageRequest request = baseRequest(pdfBytes, "webp");
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
.thenReturn(tinyDocument(1));
// ProcessExecutor instance + result are mocked; the empty output dir drives the
// "No WebP files were created" IOException without invoking Python.
ProcessExecutorResult procResult = Mockito.mock(ProcessExecutorResult.class);
Mockito.when(procResult.getMessages()).thenReturn("no output");
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
Mockito.when(executor.runCommandWithOutputHandling(anyList())).thenReturn(procResult);
// parsePageList/generateFilename are stubbed so rearrangePdfPages runs without
// touching the real installation path for script extraction.
java.util.List<Integer> pageOrder = java.util.List.of(0);
Path scriptPath = Path.of("png_to_webp.py");
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
MockedStatic<CheckProgramInstall> cpi =
Mockito.mockStatic(CheckProgramInstall.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
pu.when(
() ->
PdfUtils.convertFromPdf(
eq(pdfDocumentFactory),
any(byte[].class),
eq("png"),
any(ImageType.class),
anyBoolean(),
anyInt(),
any(String.class),
anyBoolean()))
.thenReturn("png-image".getBytes());
gu.when(
() ->
GeneralUtils.parsePageList(
any(String[].class), anyInt(), anyBoolean()))
.thenReturn(pageOrder);
gu.when(() -> GeneralUtils.generateFilename(any(), any(String.class)))
.thenReturn("out");
gu.when(() -> GeneralUtils.extractScript("png_to_webp.py")).thenReturn(scriptPath);
cpi.when(CheckProgramInstall::isPythonAvailable).thenReturn(true);
cpi.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3");
pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
.thenReturn(executor);
// Output directory is empty after the (mocked) run, so the controller throws.
assertThatThrownBy(() -> controller.convertToImage(request))
.isInstanceOf(IOException.class)
.hasMessageContaining("No WebP files were created");
}
}
}
}
@@ -0,0 +1,253 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.Method;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.rendering.ImageType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertToImageRequest;
import stirling.software.SPDF.model.api.converters.ConvertToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
/**
* Additional branch coverage for {@link ConvertImgPDFController}: the getMediaType fallback, the
* convertToPdf null/blank colorType and fitOption defaults, multi-image input, and the explicit
* page-selection path of convertToImage. The PdfUtils boundary is mocked so no real rendering or
* external binary runs.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ConvertImgPDFController additional branch tests")
class ConvertImgPDFControllerMoreTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private EndpointConfiguration endpointConfiguration;
@InjectMocks private ConvertImgPDFController controller;
private static byte[] tinyPdfBytes(int pages) throws IOException {
try (PDDocument doc = new PDDocument();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
doc.save(baos);
return baos.toByteArray();
}
}
private static PDDocument tinyDoc(int pages) {
PDDocument doc = new PDDocument();
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
return doc;
}
@Nested
@DisplayName("getMediaType")
class GetMediaType {
private String invoke(String format) throws Exception {
Method m =
ConvertImgPDFController.class.getDeclaredMethod("getMediaType", String.class);
m.setAccessible(true);
return (String) m.invoke(controller, format);
}
@Test
@DisplayName("known image extension resolves to a concrete mime type")
void knownExtension() throws Exception {
assertThat(invoke("png")).isEqualTo("image/png");
}
@Test
@DisplayName("unknown extension does not resolve to a concrete image mime type")
void unknownExtensionNotConcrete() throws Exception {
// guessContentTypeFromName yields null/octet-stream for an unrecognised extension;
// either way it must not masquerade as a real image type.
String result = invoke("zzz");
assertThat(result).isNotEqualTo("image/png");
}
}
@Nested
@DisplayName("convertToPdf defaults")
class ConvertToPdfDefaults {
@Test
@DisplayName("blank colorType and empty fitOption fall back to color/fillPage")
void blankDefaults() throws Exception {
MockMultipartFile img =
new MockMultipartFile("fileInput", "p.jpg", "image/jpeg", "x".getBytes());
ConvertToPdfRequest request = new ConvertToPdfRequest();
request.setFileInput(new MockMultipartFile[] {img});
request.setColorType(" ");
request.setFitOption("");
request.setAutoRotate(null);
byte[] pdfBytes = "pdf".getBytes();
ResponseEntity<byte[]> expected = ResponseEntity.ok(pdfBytes);
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
pu.when(
() ->
PdfUtils.imageToPdf(
any(MockMultipartFile[].class),
eq("fillPage"),
eq(false),
eq("color"),
eq(pdfDocumentFactory)))
.thenReturn(pdfBytes);
gu.when(() -> GeneralUtils.generateFilename("p.jpg", "_converted.pdf"))
.thenReturn("p_converted.pdf");
wr.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "p_converted.pdf"))
.thenReturn(expected);
ResponseEntity<byte[]> response = controller.convertToPdf(request);
assertThat(response).isSameAs(expected);
// Blank colorType -> "color"; empty fitOption -> "fillPage".
pu.verify(
() ->
PdfUtils.imageToPdf(
any(MockMultipartFile[].class),
eq("fillPage"),
eq(false),
eq("color"),
eq(pdfDocumentFactory)));
}
}
@Test
@DisplayName("multiple images use the first filename for the output")
void multipleImagesUseFirstName() throws Exception {
MockMultipartFile a =
new MockMultipartFile("fileInput", "first.png", "image/png", "a".getBytes());
MockMultipartFile b =
new MockMultipartFile("fileInput", "second.png", "image/png", "b".getBytes());
ConvertToPdfRequest request = new ConvertToPdfRequest();
request.setFileInput(new MockMultipartFile[] {a, b});
request.setColorType("color");
request.setFitOption("fillPage");
request.setAutoRotate(false);
byte[] pdfBytes = "pdf".getBytes();
ResponseEntity<byte[]> expected = ResponseEntity.ok(pdfBytes);
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
pu.when(
() ->
PdfUtils.imageToPdf(
any(MockMultipartFile[].class),
eq("fillPage"),
eq(false),
eq("color"),
eq(pdfDocumentFactory)))
.thenReturn(pdfBytes);
gu.when(() -> GeneralUtils.generateFilename("first.png", "_converted.pdf"))
.thenReturn("first_converted.pdf");
wr.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "first_converted.pdf"))
.thenReturn(expected);
ResponseEntity<byte[]> response = controller.convertToPdf(request);
assertThat(response).isSameAs(expected);
gu.verify(() -> GeneralUtils.generateFilename("first.png", "_converted.pdf"));
}
}
}
@Nested
@DisplayName("convertToImage explicit page selection")
class ExplicitPages {
@Test
@DisplayName("a specific page-number list is parsed and rendered")
void specificPageList() throws Exception {
byte[] pdfBytes = tinyPdfBytes(3);
MockMultipartFile file =
new MockMultipartFile("fileInput", "src.pdf", "application/pdf", pdfBytes);
ConvertToImageRequest request = new ConvertToImageRequest();
request.setFileInput(file);
request.setImageFormat("png");
request.setSingleOrMultiple("single");
request.setColorType("color");
request.setDpi(72);
request.setPageNumbers("1,3");
request.setIncludeAnnotations(false);
// rearrangePdfPages loads a real document and selects pages 1 and 3.
Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
.thenReturn(tinyDoc(3));
byte[] imageBytes = "img".getBytes();
ResponseEntity<byte[]> expected = ResponseEntity.ok(imageBytes);
try (MockedStatic<PdfUtils> pu = Mockito.mockStatic(PdfUtils.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
pu.when(
() ->
PdfUtils.convertFromPdf(
eq(pdfDocumentFactory),
any(byte[].class),
eq("PNG"),
eq(ImageType.RGB),
eq(true),
eq(72),
any(String.class),
eq(false)))
.thenReturn(imageBytes);
wr.when(
() ->
WebResponseUtils.bytesToWebResponse(
eq(imageBytes),
any(String.class),
any(MediaType.class)))
.thenReturn(expected);
ResponseEntity<?> response = controller.convertToImage(request);
assertThat(response).isSameAs(expected);
}
}
}
}
@@ -0,0 +1,274 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.commonmark.node.Node;
import org.commonmark.parser.Parser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
import stirling.software.common.util.FileToPdf;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
/**
* Additional coverage for {@link ConvertMarkdownToPdf}. Real commonmark parsing is exercised; the
* external WeasyPrint boundary ({@link FileToPdf#convertHtmlToPdf}) and the response writer are
* mocked so no external tool is launched. The ZIP branch uses a real in-memory ZIP archive.
*/
@ExtendWith(MockitoExtension.class)
class ConvertMarkdownToPdfMoreTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private TempFileManager tempFileManager;
@Mock private CustomHtmlSanitizer customHtmlSanitizer;
@InjectMocks private ConvertMarkdownToPdf controller;
@BeforeEach
void setUp() throws Exception {
lenient().when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/usr/bin/weasyprint");
lenient()
.when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(any(byte[].class)))
.thenAnswer(inv -> inv.getArgument(0));
// A real managed temp file so Files.write succeeds before the (mocked) response build.
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("md-test", inv.<String>getArgument(0))
.toFile();
f.deleteOnExit();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
// A real temp directory backing the ZIP-extraction branch.
lenient()
.when(tempFileManager.createTempDirectory())
.thenAnswer(inv -> Files.createTempDirectory("md-zip-test"));
}
private static ResponseEntity<Resource> cannedResponse() {
return ResponseEntity.ok(new ByteArrayResource("pdf".getBytes()));
}
private GeneralFile generalFileOf(String name, String contentType, byte[] bytes) {
GeneralFile gf = new GeneralFile();
gf.setFileInput(new MockMultipartFile("fileInput", name, contentType, bytes));
return gf;
}
@Nested
@DisplayName("plain markdown branch")
class PlainMarkdown {
@Test
@DisplayName("converts a markdown file with a GFM table to PDF")
void markdownWithTable() throws Exception {
String md = "# Title\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\nSome **bold** body text.\n";
GeneralFile gf = generalFileOf("doc.md", "text/markdown", md.getBytes());
try (MockedStatic<FileToPdf> ftp = Mockito.mockStatic(FileToPdf.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
ArgumentCaptor<byte[]> htmlBytes = ArgumentCaptor.forClass(byte[].class);
ftp.when(
() ->
FileToPdf.convertHtmlToPdf(
eq("/usr/bin/weasyprint"),
isNull(),
htmlBytes.capture(),
eq("converted.html"),
eq(tempFileManager),
eq(customHtmlSanitizer)))
.thenReturn("pdf".getBytes());
wr.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(cannedResponse());
ResponseEntity<Resource> response = controller.markdownToPdf(gf);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
String html = new String(htmlBytes.getValue(), StandardCharsets.UTF_8);
// commonmark + tables extension produced a styled table and a heading.
assertThat(html).contains("table table-striped");
assertThat(html).contains("<h1>");
}
}
@Test
@DisplayName("propagates a WeasyPrint conversion failure")
void weasyPrintFailurePropagates() throws Exception {
GeneralFile gf = generalFileOf("doc.md", "text/markdown", "# Hi".getBytes());
try (MockedStatic<FileToPdf> ftp = Mockito.mockStatic(FileToPdf.class)) {
ftp.when(
() ->
FileToPdf.convertHtmlToPdf(
anyString(),
isNull(),
any(byte[].class),
anyString(),
any(TempFileManager.class),
any(CustomHtmlSanitizer.class)))
.thenThrow(new java.io.IOException("weasyprint failed"));
assertThatThrownBy(() -> controller.markdownToPdf(gf))
.isInstanceOf(java.io.IOException.class);
}
}
}
@Nested
@DisplayName("zip markdown branch")
class ZipMarkdown {
private byte[] zipWith(Map<String, byte[]> entries) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
for (Map.Entry<String, byte[]> e : entries.entrySet()) {
zos.putNextEntry(new ZipEntry(e.getKey()));
zos.write(e.getValue());
zos.closeEntry();
}
}
return baos.toByteArray();
}
@Test
@DisplayName("extracts markdown plus an image and converts via the zip path")
void zipWithImageConverts() throws Exception {
Map<String, byte[]> entries = new HashMap<>();
entries.put("index.md", "# Zip Doc\n\n![img](pic.png)\n".getBytes());
entries.put("pic.png", new byte[] {(byte) 0x89, 'P', 'N', 'G'});
byte[] zip = zipWith(entries);
GeneralFile gf = generalFileOf("bundle.zip", "application/zip", zip);
try (MockedStatic<FileToPdf> ftp = Mockito.mockStatic(FileToPdf.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
ftp.when(
() ->
FileToPdf.convertHtmlToPdf(
eq("/usr/bin/weasyprint"),
isNull(),
any(byte[].class),
eq("package.zip"),
eq(tempFileManager),
eq(customHtmlSanitizer)))
.thenReturn("pdf".getBytes());
wr.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(cannedResponse());
ResponseEntity<Resource> response = controller.markdownToPdf(gf);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
ftp.verify(
() ->
FileToPdf.convertHtmlToPdf(
anyString(),
isNull(),
any(byte[].class),
eq("package.zip"),
any(TempFileManager.class),
any(CustomHtmlSanitizer.class)));
}
}
@Test
@DisplayName("throws when the zip contains no markdown file")
void zipWithoutMarkdownThrows() throws Exception {
Map<String, byte[]> entries = new HashMap<>();
entries.put("readme.txt", "not markdown".getBytes());
byte[] zip = zipWith(entries);
GeneralFile gf = generalFileOf("bundle.zip", "application/zip", zip);
assertThatThrownBy(() -> controller.markdownToPdf(gf))
.isInstanceOf(IllegalArgumentException.class);
}
}
@Nested
@DisplayName("input validation")
class Validation {
@Test
@DisplayName("throws when fileInput is null")
void nullFileInput() {
GeneralFile gf = new GeneralFile();
gf.setFileInput(null);
assertThatThrownBy(() -> controller.markdownToPdf(gf))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("throws for a non-markdown, non-zip extension")
void wrongExtension() {
GeneralFile gf = generalFileOf("notes.txt", "text/plain", "hello".getBytes());
assertThatThrownBy(() -> controller.markdownToPdf(gf))
.isInstanceOf(IllegalArgumentException.class);
}
}
@Test
@DisplayName("TableAttributeProvider adds the table class only to table blocks")
void tableAttributeProviderBehaviour() {
Parser parser = Parser.builder().build();
Node paragraph = parser.parse("plain paragraph");
TableAttributeProvider provider = new TableAttributeProvider();
Map<String, String> attrs = new HashMap<>();
// A non-table node leaves the attribute map untouched.
provider.setAttributes(paragraph.getFirstChild(), "p", attrs);
assertThat(attrs).doesNotContainKey("class");
}
}
@@ -0,0 +1,458 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.OfficeDocumentSanitizer;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.ProcessExecutor.Processes;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
/**
* Unit tests for {@link ConvertOfficeController}. The external LibreOffice/unoconvert boundary is
* mocked via mockStatic(ProcessExecutor) so no real process is spawned.
*/
@DisplayName("ConvertOfficeController tests")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ConvertOfficeControllerTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private CustomHtmlSanitizer customHtmlSanitizer;
@Mock private OfficeDocumentSanitizer officeDocumentSanitizer;
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private TempFileManager tempFileManager;
private ConvertOfficeController controller;
private ConvertOfficeController newController() {
return new ConvertOfficeController(
pdfDocumentFactory,
runtimePathConfig,
customHtmlSanitizer,
officeDocumentSanitizer,
endpointConfiguration,
tempFileManager);
}
@BeforeEach
void setUp() {
controller = newController();
lenient().when(runtimePathConfig.getSOfficePath()).thenReturn("soffice");
lenient().when(runtimePathConfig.getUnoConvertPath()).thenReturn("unoconvert");
}
private static ResponseEntity<Resource> streamingOk(byte[] bytes) {
return ResponseEntity.ok(new ByteArrayResource(bytes));
}
// ---- reflection helper for the private convertToPdf-supporting methods --------------------
@SuppressWarnings("unchecked")
private <T> T invokeInstance(String methodName, Object... args) throws Exception {
for (Method method : ConvertOfficeController.class.getDeclaredMethods()) {
if (method.getName().equals(methodName) && method.getParameterCount() == args.length) {
method.setAccessible(true);
try {
return (T) method.invoke(controller, args);
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof Exception ex) {
throw ex;
}
throw new RuntimeException(cause);
}
}
}
throw new IllegalStateException(
"No method " + methodName + " with " + args.length + " args");
}
private MockMultipartFile docxFile(byte[] content) {
return new MockMultipartFile(
"fileInput",
"report.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
content);
}
/**
* Configures the static ProcessExecutor so the LibreOffice/uno call returns rc and writes a pdf
* to the outdir if requested.
*/
private ProcessExecutorResult mockExecutor(MockedStatic<ProcessExecutor> pe, int rc) {
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
pe.when(() -> ProcessExecutor.getInstance(Processes.LIBRE_OFFICE)).thenReturn(executor);
ProcessExecutorResult result = Mockito.mock(ProcessExecutorResult.class);
when(result.getRc()).thenReturn(rc);
return result;
}
@Nested
@DisplayName("isValidFileExtension")
class ExtensionValidation {
@Test
@DisplayName("accepts 2-4 char alphanumeric extensions")
void acceptsValid() throws Exception {
assertThat((boolean) invokeInstance("isValidFileExtension", "docx")).isTrue();
assertThat((boolean) invokeInstance("isValidFileExtension", "odt")).isTrue();
assertThat((boolean) invokeInstance("isValidFileExtension", "xls")).isTrue();
}
@Test
@DisplayName("rejects too-long or symbol extensions")
void rejectsInvalid() throws Exception {
assertThat((boolean) invokeInstance("isValidFileExtension", "toolongext")).isFalse();
assertThat((boolean) invokeInstance("isValidFileExtension", "a")).isFalse();
assertThat((boolean) invokeInstance("isValidFileExtension", "d.x")).isFalse();
}
}
@Nested
@DisplayName("convertToPdf input validation")
class InputValidation {
@Test
@DisplayName("blank filename throws file-no-name exception")
void blankFilename() {
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "", "application/octet-stream", "x".getBytes());
assertThatThrownBy(() -> controller.convertToPdf(file))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("unsupported/invalid extension throws invalid-extension exception")
void invalidExtension() {
MockMultipartFile file =
new MockMultipartFile(
"fileInput",
"archive.toolong",
"application/octet-stream",
"x".getBytes());
assertThatThrownBy(() -> controller.convertToPdf(file))
.isInstanceOf(IllegalArgumentException.class);
}
}
@Nested
@DisplayName("convertToPdf conversion paths")
class ConversionPaths {
@Test
@DisplayName("uses unoconvert when available and returns produced pdf")
void unoconvertSuccess() throws Exception {
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(true);
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
.thenAnswer(inv -> inv.getArgument(0));
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutorResult result = mockExecutor(pe, 0);
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
ArgumentCaptor<List<String>> cmd = ArgumentCaptor.forClass(List.class);
when(executor.runCommandWithOutputHandling(cmd.capture()))
.thenAnswer(
inv -> {
// unoconvert writes directly to the output path (last arg)
List<String> command = inv.getArgument(0);
Path out = Path.of(command.get(command.size() - 1));
Files.writeString(out, "%PDF-1.4 produced");
return result;
});
File pdf = controller.convertToPdf(docxFile("real-docx".getBytes()));
assertThat(pdf).exists();
assertThat(Files.size(pdf.toPath())).isGreaterThan(0L);
assertThat(cmd.getValue().get(0)).isEqualTo("unoconvert");
// sanitizer must have been consulted for the docx
Mockito.verify(officeDocumentSanitizer).sanitize(any(byte[].class), anyString());
deleteWorkdir(pdf);
}
}
@Test
@DisplayName("falls back to soffice when unoconvert is unavailable")
void sofficeFallbackWhenUnoUnavailable() throws Exception {
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
.thenAnswer(inv -> inv.getArgument(0));
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutorResult result = mockExecutor(pe, 0);
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
ArgumentCaptor<List<String>> cmd = ArgumentCaptor.forClass(List.class);
when(executor.runCommandWithOutputHandling(cmd.capture()))
.thenAnswer(
inv -> {
// soffice writes <basename>.pdf into the --outdir (workDir)
List<String> command = inv.getArgument(0);
Path inputPath = Path.of(command.get(command.size() - 1));
Path out = inputPath.getParent().resolve("report.pdf");
Files.writeString(out, "%PDF soffice");
return result;
});
File pdf = controller.convertToPdf(docxFile("real-docx".getBytes()));
assertThat(pdf).exists();
assertThat(cmd.getValue().get(0)).isEqualTo("soffice");
assertThat(cmd.getValue()).contains("--headless", "--convert-to", "pdf");
deleteWorkdir(pdf);
}
}
@Test
@DisplayName("non-zero exit code throws IllegalStateException")
void nonZeroExit() throws Exception {
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
.thenAnswer(inv -> inv.getArgument(0));
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutorResult result = mockExecutor(pe, 3);
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(result);
assertThatThrownBy(() -> controller.convertToPdf(docxFile("docx".getBytes())))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("exit 3");
}
}
@Test
@DisplayName("no produced pdf (rc 0 but no file) throws IllegalStateException")
void noProducedPdf() throws Exception {
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
.thenAnswer(inv -> inv.getArgument(0));
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutorResult result = mockExecutor(pe, 0);
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
// rc 0 but nothing written to workDir -> "No PDF produced."
when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(result);
assertThatThrownBy(() -> controller.convertToPdf(docxFile("docx".getBytes())))
.isInstanceOf(IllegalStateException.class);
}
}
@Test
@DisplayName("empty produced pdf throws IllegalStateException")
void emptyProducedPdf() throws Exception {
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
.thenAnswer(inv -> inv.getArgument(0));
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutorResult result = mockExecutor(pe, 0);
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
when(executor.runCommandWithOutputHandling(any(List.class)))
.thenAnswer(
inv -> {
List<String> command = inv.getArgument(0);
Path inputPath = Path.of(command.get(command.size() - 1));
Path out = inputPath.getParent().resolve("report.pdf");
Files.write(out, new byte[0]);
return result;
});
assertThatThrownBy(() -> controller.convertToPdf(docxFile("docx".getBytes())))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("empty");
}
}
@Test
@DisplayName("html input is routed through the html sanitizer")
void htmlSanitized() throws Exception {
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
when(customHtmlSanitizer.sanitize(anyString())).thenReturn("<html>clean</html>");
MockMultipartFile html =
new MockMultipartFile(
"fileInput",
"page.html",
"text/html",
"<html><body>hi</body></html>".getBytes(StandardCharsets.UTF_8));
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutorResult result = mockExecutor(pe, 0);
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
when(executor.runCommandWithOutputHandling(any(List.class)))
.thenAnswer(
inv -> {
List<String> command = inv.getArgument(0);
Path inputPath = Path.of(command.get(command.size() - 1));
Path out = inputPath.getParent().resolve("page.pdf");
Files.writeString(out, "%PDF html");
return result;
});
File pdf = controller.convertToPdf(html);
assertThat(pdf).exists();
Mockito.verify(customHtmlSanitizer).sanitize(anyString());
Mockito.verifyNoInteractions(officeDocumentSanitizer);
deleteWorkdir(pdf);
}
}
}
@Nested
@DisplayName("processFileToPDF endpoint")
class EndpointTests {
@Test
@DisplayName("happy path loads, saves and cleans up the work directory")
void happyPath() throws Exception {
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
.thenAnswer(inv -> inv.getArgument(0));
File tempOutFile = Files.createTempFile(tempDir, "out", ".pdf").toFile();
TempFile tempOut = mock(TempFile.class);
when(tempOut.getFile()).thenReturn(tempOutFile);
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(tempOut);
PDDocument doc = new PDDocument();
doc.addPage(new PDPage());
when(pdfDocumentFactory.load(any(File.class))).thenReturn(doc);
GeneralFile generalFile = new GeneralFile();
generalFile.setFileInput(docxFile("docx-bytes".getBytes()));
ResponseEntity<Resource> expected = streamingOk("pdf".getBytes());
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ProcessExecutorResult result = mockExecutor(pe, 0);
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
when(executor.runCommandWithOutputHandling(any(List.class)))
.thenAnswer(
inv -> {
List<String> command = inv.getArgument(0);
Path inputPath = Path.of(command.get(command.size() - 1));
Path out = inputPath.getParent().resolve("report.pdf");
Files.writeString(out, "%PDF produced");
return result;
});
gu.when(() -> GeneralUtils.generateFilename(anyString(), anyString()))
.thenReturn("report_convertedToPDF.pdf");
wr.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(expected);
ResponseEntity<Resource> response = controller.processFileToPDF(generalFile);
assertThat(response).isSameAs(expected);
wr.verify(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()));
}
doc.close();
}
@Test
@DisplayName("conversion failure propagates and does not return a response")
void conversionFailurePropagates() throws Exception {
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true);
when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString()))
.thenAnswer(inv -> inv.getArgument(0));
GeneralFile generalFile = new GeneralFile();
generalFile.setFileInput(docxFile("docx".getBytes()));
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutorResult result = mockExecutor(pe, 1);
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(result);
assertThatThrownBy(() -> controller.processFileToPDF(generalFile))
.isInstanceOf(IllegalStateException.class);
// a failed conversion never reaches the document factory
Mockito.verifyNoInteractions(pdfDocumentFactory);
}
}
}
private static void deleteWorkdir(File producedPdf) throws IOException {
if (producedPdf != null && producedPdf.getParentFile() != null) {
org.apache.commons.io.FileUtils.deleteDirectory(producedPdf.getParentFile());
}
}
}
@@ -0,0 +1,215 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.nio.file.Files;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
/**
* Additional coverage for {@link ConvertPDFToExcelController}. Tabula runs in-process, so documents
* are built in-memory: an empty page exercises the no-content branch, a multi-page document drives
* the page loop, and a bordered-grid page drives the workbook-writing path. The managed temp file
* is a real file so the workbook is written to disk.
*/
@ExtendWith(MockitoExtension.class)
class ConvertPDFToExcelControllerMoreTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private ConvertPDFToExcelController controller;
@BeforeEach
void setUp() throws Exception {
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("xlsx-test", inv.<String>getArgument(0))
.toFile();
f.deleteOnExit();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
}
private static MockMultipartFile pdf(String name) {
return new MockMultipartFile("fileInput", name, "application/pdf", "pdf".getBytes());
}
private static PDDocument blankPages(int pages) {
PDDocument doc = new PDDocument();
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
return doc;
}
/** Build a single-page document with a drawn bordered grid that Tabula can detect. */
private static PDDocument borderedTableDoc() throws Exception {
PDDocument doc = new PDDocument();
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
float left = 60f;
float top = 700f;
float colW = 120f;
float rowH = 30f;
int cols = 3;
int rows = 3;
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setLineWidth(1f);
// Horizontal rules.
for (int r = 0; r <= rows; r++) {
float y = top - r * rowH;
cs.moveTo(left, y);
cs.lineTo(left + cols * colW, y);
cs.stroke();
}
// Vertical rules.
for (int c = 0; c <= cols; c++) {
float x = left + c * colW;
cs.moveTo(x, top);
cs.lineTo(x, top - rows * rowH);
cs.stroke();
}
// Cell text.
PDType1Font font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
cs.beginText();
cs.setFont(font, 10);
cs.newLineAtOffset(left + c * colW + 5, top - r * rowH - 20);
cs.showText("R" + r + "C" + c);
cs.endText();
}
}
}
return doc;
}
@Nested
@DisplayName("no-content branches")
class NoContent {
@Test
@DisplayName("single blank page yields no content")
void blankPageNoContent() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("data.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenReturn(blankPages(1));
ResponseEntity<Resource> response = controller.pdfToExcel(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
@Test
@DisplayName("multi-page blank document iterates all pages then yields no content")
void multiBlankPagesNoContent() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("multi.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenReturn(blankPages(3));
ResponseEntity<Resource> response = controller.pdfToExcel(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
}
@Nested
@DisplayName("workbook-writing branch")
class WorkbookWriting {
@Test
@DisplayName(
"a bordered table page produces an xlsx response or, if undetected, no content")
void borderedTableProducesXlsx() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("table.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenReturn(borderedTableDoc());
ResponseEntity<Resource> response = controller.pdfToExcel(request);
// Lattice detection depends on the Tabula build; accept either outcome but assert the
// success path produced a real, non-empty xlsx body.
if (response.getStatusCode() == HttpStatus.OK) {
assertThat(response.getHeaders().getContentType().toString())
.contains("spreadsheetml.sheet");
assertThat(response.getHeaders().getContentDisposition().getFilename())
.isEqualTo("table.xlsx");
assertThat(response.getBody()).isNotNull();
} else {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
}
}
@Nested
@DisplayName("error propagation")
class Errors {
@Test
@DisplayName("propagates a document load failure and closes the temp file")
void loadFailurePropagates() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("corrupt.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenThrow(new java.io.IOException("load boom"));
assertThatThrownBy(() -> controller.pdfToExcel(request))
.isInstanceOf(java.io.IOException.class);
}
}
@Test
@DisplayName("sanity: blank pages produce a parseable empty document")
void blankDocumentSanity() throws Exception {
try (PDDocument doc = blankPages(1);
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
doc.save(out);
assertThat(out.size()).isPositive();
}
}
}
@@ -0,0 +1,329 @@
package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.converters.PdfToPresentationRequest;
import stirling.software.SPDF.model.api.converters.PdfToTextOrRTFRequest;
import stirling.software.SPDF.model.api.converters.PdfToWordRequest;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
/**
* Additional tests for {@link ConvertPDFToOffice}. The office-format conversions delegate to {@code
* PDFToFile.processPdfToOfficeFormat}, which shells out to LibreOffice through the static {@link
* ProcessExecutor} factory. Here that factory is mocked with {@code mockStatic}; the mocked
* command-runner writes the expected output file into the LibreOffice {@code --outdir} so the real
* {@code PDFToFile} flow completes and a file-backed response is produced. No real LibreOffice
* runs.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ConvertPDFToOfficeMoreTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private RuntimePathConfig runtimePathConfig;
@InjectMocks private ConvertPDFToOffice controller;
@BeforeEach
void setUp() throws Exception {
// Real temp files backing TempFileManager so the file-backed response can be read back.
lenient()
.when(tempFileManager.createManagedTempFile(any()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("conv-out", inv.<String>getArgument(0))
.toFile();
f.deleteOnExit();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
// PDFToFile creates its own TempFile(manager, suffix) which calls manager.createTempFile.
lenient()
.when(tempFileManager.createTempFile(any()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("conv-in", inv.<String>getArgument(0))
.toFile();
f.deleteOnExit();
return f;
});
// PDFToFile also creates a TempDirectory for LibreOffice output.
lenient()
.when(tempFileManager.createTempDirectory())
.thenAnswer(inv -> Files.createTempDirectory("conv-dir"));
// Force the soffice fallback path (uno disabled) and a deterministic soffice binary name.
lenient().when(runtimePathConfig.getUnoConvertPath()).thenReturn("");
lenient().when(runtimePathConfig.getSOfficePath()).thenReturn("soffice");
}
private MockMultipartFile pdfFile() {
return new MockMultipartFile(
"fileInput",
"document.pdf",
MediaType.APPLICATION_PDF_VALUE,
"%PDF-1.4".getBytes());
}
private MockMultipartFile nonPdfFile() {
return new MockMultipartFile(
"fileInput", "document.txt", MediaType.TEXT_PLAIN_VALUE, "hello".getBytes());
}
private static byte[] readResource(Resource resource) throws IOException {
try (InputStream in = resource.getInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
in.transferTo(baos);
return baos.toByteArray();
}
}
/**
* Stubs the LibreOffice executor so that running the soffice command writes a fake output file
* (named {@code document.<ext>}) into the directory that follows {@code --outdir}.
*/
private void stubLibreOfficeWritesOutput(
MockedStatic<ProcessExecutor> mockedFactory, String primaryExt) throws Exception {
ProcessExecutor executor = mock(ProcessExecutor.class);
ProcessExecutorResult okResult = mock(ProcessExecutorResult.class);
lenient().when(okResult.getRc()).thenReturn(0);
when(executor.runCommandWithOutputHandling(any()))
.thenAnswer(
inv -> {
List<String> cmd = inv.getArgument(0);
int outDirIdx = cmd.indexOf("--outdir");
Path outDir = Path.of(cmd.get(outDirIdx + 1));
Path outFile = outDir.resolve("document." + primaryExt);
Files.write(
outFile, "converted-bytes".getBytes(StandardCharsets.UTF_8));
return okResult;
});
mockedFactory
.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE))
.thenReturn(executor);
}
@Nested
@DisplayName("Presentation conversion")
class PresentationConversion {
@Test
@DisplayName("pptx output streams the converted file back with 200")
void presentationPptxSuccess() throws Exception {
PdfToPresentationRequest request = new PdfToPresentationRequest();
request.setFileInput(pdfFile());
request.setOutputFormat("pptx");
try (MockedStatic<ProcessExecutor> mockedFactory = mockStatic(ProcessExecutor.class)) {
stubLibreOfficeWritesOutput(mockedFactory, "pptx");
ResponseEntity<Resource> response = controller.processPdfToPresentation(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(readResource(response.getBody()).length > 0);
}
}
@Test
@DisplayName("non-PDF input returns 400 without invoking LibreOffice")
void presentationNonPdfReturnsBadRequest() throws Exception {
PdfToPresentationRequest request = new PdfToPresentationRequest();
request.setFileInput(nonPdfFile());
request.setOutputFormat("pptx");
ResponseEntity<Resource> response = controller.processPdfToPresentation(request);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
}
@Test
@DisplayName("LibreOffice IOException propagates from presentation conversion")
void presentationLibreOfficeFailurePropagates() throws Exception {
PdfToPresentationRequest request = new PdfToPresentationRequest();
request.setFileInput(pdfFile());
request.setOutputFormat("pptx");
try (MockedStatic<ProcessExecutor> mockedFactory = mockStatic(ProcessExecutor.class)) {
ProcessExecutor executor = mock(ProcessExecutor.class);
when(executor.runCommandWithOutputHandling(any()))
.thenThrow(new IOException("soffice crashed"));
mockedFactory
.when(
() ->
ProcessExecutor.getInstance(
ProcessExecutor.Processes.LIBRE_OFFICE))
.thenReturn(executor);
assertThrows(IOException.class, () -> controller.processPdfToPresentation(request));
}
}
}
@Nested
@DisplayName("Word conversion")
class WordConversion {
@Test
@DisplayName("docx output streams the converted file back with 200")
void wordDocxSuccess() throws Exception {
PdfToWordRequest request = new PdfToWordRequest();
request.setFileInput(pdfFile());
request.setOutputFormat("docx");
try (MockedStatic<ProcessExecutor> mockedFactory = mockStatic(ProcessExecutor.class)) {
stubLibreOfficeWritesOutput(mockedFactory, "docx");
ResponseEntity<Resource> response = controller.processPdfToWord(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(readResource(response.getBody()).length > 0);
}
}
@Test
@DisplayName("unsupported output format returns 400")
void wordUnsupportedFormatReturnsBadRequest() throws Exception {
PdfToWordRequest request = new PdfToWordRequest();
request.setFileInput(pdfFile());
request.setOutputFormat("bogus");
ResponseEntity<Resource> response = controller.processPdfToWord(request);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
}
}
@Nested
@DisplayName("Text / RTF conversion")
class TextRtfConversion {
@Test
@DisplayName("txt output uses PDFBox stripper, not LibreOffice")
void txtUsesStripper() throws Exception {
PdfToTextOrRTFRequest request = new PdfToTextOrRTFRequest();
request.setFileInput(pdfFile());
request.setOutputFormat("txt");
PDDocument realDoc = new PDDocument();
realDoc.addPage(new PDPage());
when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(realDoc);
ResponseEntity<Resource> response = controller.processPdfToRTForTXT(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(MediaType.TEXT_PLAIN, response.getHeaders().getContentType());
}
@Test
@DisplayName("rtf output delegates to LibreOffice and streams back")
void rtfDelegatesToLibreOffice() throws Exception {
PdfToTextOrRTFRequest request = new PdfToTextOrRTFRequest();
request.setFileInput(pdfFile());
request.setOutputFormat("rtf");
try (MockedStatic<ProcessExecutor> mockedFactory = mockStatic(ProcessExecutor.class)) {
stubLibreOfficeWritesOutput(mockedFactory, "rtf");
ResponseEntity<Resource> response = controller.processPdfToRTForTXT(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(readResource(response.getBody()).length > 0);
}
}
@Test
@DisplayName("txt branch closes temp file and rethrows when stripper load fails")
void txtLoadFailurePropagates() throws Exception {
PdfToTextOrRTFRequest request = new PdfToTextOrRTFRequest();
request.setFileInput(pdfFile());
request.setOutputFormat("txt");
when(pdfDocumentFactory.load(any(MockMultipartFile.class)))
.thenThrow(new IOException("cannot parse pdf"));
IOException thrown =
assertThrows(IOException.class, () -> controller.processPdfToRTForTXT(request));
assertEquals("cannot parse pdf", thrown.getMessage());
}
}
@Nested
@DisplayName("XML conversion")
class XmlConversion {
@Test
@DisplayName("xml output streams the converted file back with 200")
void xmlSuccess() throws Exception {
PDFFile file = new PDFFile();
file.setFileInput(pdfFile());
try (MockedStatic<ProcessExecutor> mockedFactory = mockStatic(ProcessExecutor.class)) {
stubLibreOfficeWritesOutput(mockedFactory, "xml");
ResponseEntity<Resource> response = controller.processPdfToXML(file);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertTrue(readResource(response.getBody()).length > 0);
}
}
@Test
@DisplayName("non-PDF input returns 400 for xml conversion")
void xmlNonPdfReturnsBadRequest() throws Exception {
PDFFile file = new PDFFile();
file.setFileInput(nonPdfFile());
ResponseEntity<Resource> response = controller.processPdfToXML(file);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
}
}
}
@@ -0,0 +1,591 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
import stirling.software.SPDF.service.VeraPDFService;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
/**
* Additional coverage for {@link ConvertPDFToPDFA} focusing on the end-to-end conversion flows
* (handlePdfAConversion / handlePdfXConversion / convertPDDocumentToPDFA) and the Ghostscript
* command builders. The external ghostscript/qpdf boundary is mocked with mockStatic, so no real
* binary is ever executed.
*/
@DisplayName("ConvertPDFToPDFA additional flow tests")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ConvertPDFToPDFAMoreTest {
@TempDir Path tempDir;
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private VeraPDFService veraPDFService;
@Mock private TempFileManager tempFileManager;
private ConvertPDFToPDFA newController() {
return new ConvertPDFToPDFA(runtimePathConfig, veraPDFService, tempFileManager);
}
private static ResponseEntity<Resource> streamingOk(byte[] bytes) {
return ResponseEntity.ok(new ByteArrayResource(bytes));
}
// ---- reflection helpers ----------------------------------------------------------------
@SuppressWarnings("unchecked")
private <T> T invokeInstance(Object target, String methodName, Object... args)
throws Exception {
Method method = findMethod(methodName, args.length);
method.setAccessible(true);
try {
return (T) method.invoke(target, args);
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof Exception ex) {
throw ex;
}
throw new RuntimeException(cause);
}
}
private static Method findMethod(String methodName, int argCount) {
for (Method method : ConvertPDFToPDFA.class.getDeclaredMethods()) {
if (method.getName().equals(methodName) && method.getParameterCount() == argCount) {
return method;
}
}
throw new IllegalStateException(
"No method " + methodName + " with " + argCount + " params");
}
private static Object resolvePdfaProfile(String token) throws Exception {
Class<?> enumClass = null;
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
if (inner.getSimpleName().equals("PdfaProfile")) {
enumClass = inner;
}
}
Method m = enumClass.getDeclaredMethod("fromRequest", String.class);
m.setAccessible(true);
return m.invoke(null, token);
}
private static Object resolvePdfXProfile(String token) throws Exception {
Class<?> enumClass = null;
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
if (inner.getSimpleName().equals("PdfXProfile")) {
enumClass = inner;
}
}
Method m = enumClass.getDeclaredMethod("fromRequest", String.class);
m.setAccessible(true);
return m.invoke(null, token);
}
// ---- pdf builders ----------------------------------------------------------------------
private PDDocument simplePdf() throws IOException {
PDDocument document = new PDDocument();
PDPage page = new PDPage(PDRectangle.A4);
document.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(document, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(100, 700);
cs.showText("hello world");
cs.endText();
}
return document;
}
private byte[] simplePdfBytes() throws IOException {
try (PDDocument document = simplePdf()) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return baos.toByteArray();
}
}
private MockMultipartFile pdfFile() throws IOException {
return new MockMultipartFile("fileInput", "input.pdf", "application/pdf", simplePdfBytes());
}
/**
* Sets up a single ProcessExecutor mock returned for every Processes value. The command list
* decides the result: ghostscript conversion commands write a valid output pdf, version/probe
* commands return rc 0, everything else returns rc 0 without side effects.
*/
private ProcessExecutor wireProcessExecutor(MockedStatic<ProcessExecutor> pe, int gsConvertRc)
throws Exception {
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
pe.when(() -> ProcessExecutor.getInstance(any(ProcessExecutor.Processes.class)))
.thenReturn(executor);
pe.when(
() ->
ProcessExecutor.getInstance(
any(ProcessExecutor.Processes.class), Mockito.anyBoolean()))
.thenReturn(executor);
ProcessExecutorResult okResult = mock(ProcessExecutorResult.class);
lenient().when(okResult.getRc()).thenReturn(0);
ProcessExecutorResult gsResult = mock(ProcessExecutorResult.class);
lenient().when(gsResult.getRc()).thenReturn(gsConvertRc);
lenient().when(gsResult.getMessages()).thenReturn("gs output");
lenient()
.when(executor.runCommandWithOutputHandling(any(List.class)))
.thenAnswer(
invocation -> {
List<String> command = invocation.getArgument(0);
// The real ghostscript conversion command contains -sOutputFile=...
String outFileArg =
command.stream()
.filter(a -> a.startsWith("-sOutputFile="))
.findFirst()
.orElse(null);
if (outFileArg != null) {
Path out = Path.of(outFileArg.substring("-sOutputFile=".length()));
if (gsConvertRc == 0) {
Files.write(out, simplePdfBytes());
}
return gsResult;
}
// qpdf normalize/clean writes its (last-arg) output file
if (command.contains("--normalize-content=y")) {
// qpdf produced file is the last argument
Path out = Path.of(command.get(command.size() - 1));
Files.write(out, simplePdfBytes());
}
return okResult;
});
return executor;
}
private TempFile managedTempFile() throws IOException {
File f = Files.createTempFile(tempDir, "managed", ".pdf").toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
}
@Nested
@DisplayName("isGhostscriptAvailable")
class GhostscriptAvailability {
@Test
@DisplayName("true when gs --version returns rc 0")
void availableWhenRcZero() throws Exception {
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
wireProcessExecutor(pe, 0);
boolean available = invokeInstance(newController(), "isGhostscriptAvailable");
assertThat(available).isTrue();
}
}
@Test
@DisplayName("false when probe throws")
void unavailableWhenThrows() throws Exception {
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
pe.when(() -> ProcessExecutor.getInstance(any(ProcessExecutor.Processes.class)))
.thenReturn(executor);
when(executor.runCommandWithOutputHandling(any(List.class)))
.thenThrow(new IOException("gs missing"));
boolean available = invokeInstance(newController(), "isGhostscriptAvailable");
assertThat(available).isFalse();
}
}
}
@Nested
@DisplayName("handlePdfAConversion via pdfToPdfA endpoint")
class PdfAConversion {
@Test
@DisplayName("Ghostscript success path returns the produced PDF/A-2b file")
void ghostscriptSuccess() throws Exception {
TempFile managed = managedTempFile();
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(managed);
PdfToPdfARequest request = new PdfToPdfARequest();
request.setFileInput(pdfFile());
request.setOutputFormat("pdfa-2b");
ResponseEntity<Resource> expected = streamingOk("ok".getBytes());
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
wireProcessExecutor(pe, 0);
wr.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(expected);
ResponseEntity<Resource> response = newController().pdfToPdfA(request);
assertThat(response).isSameAs(expected);
wr.verify(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class),
org.mockito.ArgumentMatchers.contains("_PDFA-2b.pdf")));
}
}
@Test
@DisplayName("PDF/A-1b exercises the part-1 CIDSet/qpdf branch and succeeds")
void pdfA1Success() throws Exception {
TempFile managed = managedTempFile();
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(managed);
PdfToPdfARequest request = new PdfToPdfARequest();
request.setFileInput(pdfFile());
request.setOutputFormat("pdfa-1");
ResponseEntity<Resource> expected = streamingOk("ok".getBytes());
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
wireProcessExecutor(pe, 0);
wr.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(expected);
ResponseEntity<Resource> response = newController().pdfToPdfA(request);
assertThat(response).isSameAs(expected);
}
}
@Test
@DisplayName("strict mode runs VeraPDF and a compliant result still returns the file")
void strictCompliantSucceeds() throws Exception {
TempFile managed = managedTempFile();
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(managed);
stirling.software.SPDF.model.api.security.PDFVerificationResult ok =
new stirling.software.SPDF.model.api.security.PDFVerificationResult();
ok.setCompliant(true);
ok.setStandard("2b");
ok.setComplianceSummary("PDF/A-2b compliant");
when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok));
PdfToPdfARequest request = new PdfToPdfARequest();
request.setFileInput(pdfFile());
request.setOutputFormat("pdfa-2b");
request.setStrict(true);
ResponseEntity<Resource> expected = streamingOk("ok".getBytes());
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
wireProcessExecutor(pe, 0);
wr.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(expected);
ResponseEntity<Resource> response = newController().pdfToPdfA(request);
assertThat(response).isSameAs(expected);
Mockito.verify(veraPDFService).validatePDF(any());
}
}
}
@Nested
@DisplayName("handlePdfXConversion via pdfToPdfA endpoint")
class PdfXConversion {
@Test
@DisplayName("Ghostscript success path returns the produced PDF/X file")
void pdfXSuccess() throws Exception {
TempFile managed = managedTempFile();
when(tempFileManager.createManagedTempFile(anyString())).thenReturn(managed);
PdfToPdfARequest request = new PdfToPdfARequest();
request.setFileInput(pdfFile());
request.setOutputFormat("pdfx");
ResponseEntity<Resource> expected = streamingOk("ok".getBytes());
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
MockedStatic<WebResponseUtils> wr =
Mockito.mockStatic(WebResponseUtils.class)) {
wireProcessExecutor(pe, 0);
wr.when(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(expected);
ResponseEntity<Resource> response = newController().pdfToPdfA(request);
assertThat(response).isSameAs(expected);
wr.verify(
() ->
WebResponseUtils.pdfFileToWebResponse(
any(TempFile.class),
org.mockito.ArgumentMatchers.contains("_PDFX.pdf")));
}
}
@Test
@DisplayName("PDF/X with Ghostscript unavailable throws the conversion-failed exception")
void pdfXNoGhostscript() throws Exception {
PdfToPdfARequest request = new PdfToPdfARequest();
request.setFileInput(pdfFile());
request.setOutputFormat("pdfx");
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
// gs --version returns non-zero -> not available
wireProcessExecutor(pe, 0);
ProcessExecutor executor =
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT);
ProcessExecutorResult notAvail = mock(ProcessExecutorResult.class);
when(notAvail.getRc()).thenReturn(127);
when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(notAvail);
assertThatThrownBy(() -> newController().pdfToPdfA(request))
.isInstanceOf(RuntimeException.class);
}
}
}
@Nested
@DisplayName("convertPDDocumentToPDFA")
class ConvertDocument {
@Test
@DisplayName("Ghostscript success returns converted bytes")
void documentConversionSuccess() throws Exception {
try (PDDocument document = simplePdf();
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
wireProcessExecutor(pe, 0);
byte[] converted = newController().convertPDDocumentToPDFA(document, "pdfa-2b");
assertThat(converted).isNotNull();
assertThat(converted.length).isGreaterThan(0);
}
}
}
@Nested
@DisplayName("Ghostscript command builders")
class CommandBuilders {
@Test
@DisplayName("buildGhostscriptCommand wires PDF/A part, devices and IO files")
void buildsPdfACommand() throws Exception {
Path workingDir = Files.createDirectories(tempDir.resolve("gs"));
Path input = Files.write(workingDir.resolve("in.pdf"), simplePdfBytes());
Path output = workingDir.resolve("out.pdf");
Path rgb = Files.write(workingDir.resolve("rgb.icc"), new byte[] {1});
Path gray = Files.write(workingDir.resolve("gray.icc"), new byte[] {1});
Path defFile = Files.write(workingDir.resolve("def.ps"), new byte[] {1});
Object colorProfiles = newColorProfiles(rgb, gray);
Object profile = resolvePdfaProfile("pdfa-1");
Method m =
ConvertPDFToPDFA.class.getDeclaredMethod(
"buildGhostscriptCommand",
Path.class,
Path.class,
colorProfiles.getClass(),
Path.class,
profile.getClass(),
Path.class);
m.setAccessible(true);
@SuppressWarnings("unchecked")
List<String> command =
(List<String>)
m.invoke(
null,
input,
output,
colorProfiles,
workingDir,
profile,
defFile);
assertThat(command).isNotEmpty();
assertThat(command.get(0)).isEqualTo("gs");
assertThat(command).contains("-dPDFA=1", "-sDEVICE=pdfwrite", "-dEmbedAllFonts=true");
assertThat(command).anyMatch(a -> a.startsWith("-sOutputFile="));
}
@Test
@DisplayName("buildGhostscriptCommandX wires PDF/X version and image tuning")
void buildsPdfXCommand() throws Exception {
Path workingDir = Files.createDirectories(tempDir.resolve("gsx"));
Path input = Files.write(workingDir.resolve("in.pdf"), simplePdfBytes());
Path output = workingDir.resolve("out.pdf");
Path rgb = Files.write(workingDir.resolve("rgb.icc"), new byte[] {1});
Path gray = Files.write(workingDir.resolve("gray.icc"), new byte[] {1});
Object colorProfiles = newColorProfiles(rgb, gray);
Object profile = resolvePdfXProfile("pdfx");
Method m =
ConvertPDFToPDFA.class.getDeclaredMethod(
"buildGhostscriptCommandX",
Path.class,
Path.class,
colorProfiles.getClass(),
Path.class,
profile.getClass());
m.setAccessible(true);
@SuppressWarnings("unchecked")
List<String> command =
(List<String>)
m.invoke(null, input, output, colorProfiles, workingDir, profile);
assertThat(command).contains("-dPDFX=2008", "-sDEVICE=pdfwrite");
assertThat(command).anyMatch(a -> a.startsWith("-dColorImageResolution="));
}
@Test
@DisplayName("createPdfaDefFile writes a PDFA_def.ps with the profile title")
void createsPdfaDef() throws Exception {
Path workingDir = Files.createDirectories(tempDir.resolve("def"));
Path rgb = Files.write(workingDir.resolve("rgb.icc"), new byte[] {1});
Path gray = Files.write(workingDir.resolve("gray.icc"), new byte[] {1});
Object colorProfiles = newColorProfiles(rgb, gray);
Object profile = resolvePdfaProfile("pdfa-2b");
Method m =
ConvertPDFToPDFA.class.getDeclaredMethod(
"createPdfaDefFile",
Path.class,
colorProfiles.getClass(),
profile.getClass());
m.setAccessible(true);
Path defFile = (Path) m.invoke(null, workingDir, colorProfiles, profile);
assertThat(defFile).exists();
String content = Files.readString(defFile);
assertThat(content).contains("PDF/A-2b");
assertThat(content).contains("OutputIntent");
}
@Test
@DisplayName("prepareColorProfiles copies the sRGB icc and writes a gray profile")
void preparesColorProfiles() throws Exception {
Path workingDir = Files.createDirectories(tempDir.resolve("colors"));
Object colorProfiles =
invokeInstance(newController(), "prepareColorProfiles", workingDir);
assertThat(colorProfiles).isNotNull();
Method rgbAccessor = colorProfiles.getClass().getDeclaredMethod("rgb");
rgbAccessor.setAccessible(true);
Path rgb = (Path) rgbAccessor.invoke(colorProfiles);
assertThat(rgb).exists();
assertThat(Files.size(rgb)).isGreaterThan(0L);
}
private Object newColorProfiles(Path rgb, Path gray) throws Exception {
Class<?> recordClass = null;
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
if (inner.getSimpleName().equals("ColorProfiles")) {
recordClass = inner;
}
}
var ctor = recordClass.getDeclaredConstructor(Path.class, Path.class);
ctor.setAccessible(true);
return ctor.newInstance(rgb, gray);
}
}
@Nested
@DisplayName("qpdf helpers")
class QpdfHelpers {
@Test
@DisplayName("normalizePdfWithQpdf returns null when qpdf is unavailable")
void normalizeUnavailable() throws Exception {
Path input = Files.write(tempDir.resolve("n.pdf"), simplePdfBytes());
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
pe.when(() -> ProcessExecutor.getInstance(any(ProcessExecutor.Processes.class)))
.thenReturn(executor);
ProcessExecutorResult notAvail = mock(ProcessExecutorResult.class);
when(notAvail.getRc()).thenReturn(1);
when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(notAvail);
Path result = invokeInstance(newController(), "normalizePdfWithQpdf", input);
assertThat(result).isNull();
}
}
@Test
@DisplayName("cleanCidSetWithQpdf returns null on exception")
void cleanCidSetThrows() throws Exception {
Path input = Files.write(tempDir.resolve("c.pdf"), simplePdfBytes());
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
pe.when(() -> ProcessExecutor.getInstance(any(ProcessExecutor.Processes.class)))
.thenReturn(executor);
when(executor.runCommandWithOutputHandling(any(List.class)))
.thenThrow(new IOException("qpdf boom"));
Path result = invokeInstance(newController(), "cleanCidSetWithQpdf", input);
assertThat(result).isNull();
}
}
}
}
@@ -0,0 +1,254 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import stirling.software.SPDF.service.PdfJsonConversionService;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.util.TempFileManager;
/**
* Coverage for the remaining {@link ConvertPdfJsonController} diagnostic helpers and the job-access
* guard on the GET endpoints. The conversion service boundary is mocked so nothing is rendered.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ConvertPdfJsonController remaining branch coverage")
class ConvertPdfJsonControllerExtraTest {
@Mock private PdfJsonConversionService pdfJsonConversionService;
@Mock private TempFileManager tempFileManager;
@Mock private JobOwnershipService jobOwnershipService;
@InjectMocks private ConvertPdfJsonController controller;
@BeforeEach
void setUp() throws Exception {
Field f = ConvertPdfJsonController.class.getDeclaredField("jobOwnershipService");
f.setAccessible(true);
f.set(controller, jobOwnershipService);
}
private Object invoke(String name, Class<?>[] sig, Object... args) throws Exception {
Method m = ConvertPdfJsonController.class.getDeclaredMethod(name, sig);
m.setAccessible(true);
return m.invoke(controller, args);
}
@Nested
@DisplayName("looksLikeBase64")
class LooksLikeBase64 {
private boolean looksLikeBase64(String value) throws Exception {
return (boolean) invoke("looksLikeBase64", new Class<?>[] {String.class}, value);
}
@Test
@DisplayName("short strings are never treated as base64")
void shortNotBase64() throws Exception {
assertThat(looksLikeBase64("short")).isFalse();
}
@Test
@DisplayName("a long base64-like string is detected")
void longBase64Detected() throws Exception {
String b64 = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2Nzg5";
assertThat(looksLikeBase64(b64)).isTrue();
}
@Test
@DisplayName("a long natural-language string is not base64")
void longProseNotBase64() throws Exception {
String prose = "this is a long sentence with spaces and punctuation, not base64!!";
assertThat(looksLikeBase64(prose)).isFalse();
}
}
@Nested
@DisplayName("truncateForLog")
class TruncateForLog {
private String truncate(String value) throws Exception {
return (String) invoke("truncateForLog", new Class<?>[] {String.class}, value);
}
@Test
@DisplayName("short values are returned with whitespace normalised")
void shortNormalised() throws Exception {
assertThat(truncate("a\tb\nc")).isEqualTo("a b c");
}
@Test
@DisplayName("long values are truncated with an ellipsis suffix")
void longTruncated() throws Exception {
String value = "x".repeat(100);
String result = truncate(value);
assertThat(result).endsWith("...");
assertThat(result).hasSize(67); // 64 chars + "..."
}
}
@Nested
@DisplayName("debug/repeat-scan flag readers")
class FlagReaders {
private boolean dumpEnabled() throws Exception {
return (boolean) invoke("isPdfJsonDebugDumpEnabled", new Class<?>[] {});
}
private boolean repeatScanEnabled() throws Exception {
return (boolean) invoke("isPdfJsonRepeatScanEnabled", new Class<?>[] {});
}
@Test
@DisplayName("dump flag reflects the system property")
void dumpFlagFromProperty() throws Exception {
String prev = System.getProperty("spdf.pdfjson.dump");
try {
System.clearProperty("spdf.pdfjson.dump");
assertThat(dumpEnabled()).isFalse();
System.setProperty("spdf.pdfjson.dump", "true");
assertThat(dumpEnabled()).isTrue();
} finally {
restore("spdf.pdfjson.dump", prev);
}
}
@Test
@DisplayName("repeat-scan flag reflects the system property")
void repeatScanFromProperty() throws Exception {
String prev = System.getProperty("spdf.pdfjson.repeatScan");
try {
System.clearProperty("spdf.pdfjson.repeatScan");
assertThat(repeatScanEnabled()).isFalse();
System.setProperty("spdf.pdfjson.repeatScan", "true");
assertThat(repeatScanEnabled()).isTrue();
} finally {
restore("spdf.pdfjson.repeatScan", prev);
}
}
private void restore(String key, String prev) {
if (prev == null) {
System.clearProperty(key);
} else {
System.setProperty(key, prev);
}
}
}
@Nested
@DisplayName("logJsonResponse")
class LogJsonResponse {
@Test
@DisplayName("a null path is logged without throwing")
void nullPathHandled() throws Exception {
// exercises the early null-path guard branch
invoke(
"logJsonResponse",
new Class<?>[] {String.class, java.nio.file.Path.class},
"x",
null);
}
@Test
@DisplayName("happy path with no debug flags returns without reading the file")
void noFlagsNoRead() throws Exception {
String dumpPrev = System.getProperty("spdf.pdfjson.dump");
String scanPrev = System.getProperty("spdf.pdfjson.repeatScan");
try {
System.clearProperty("spdf.pdfjson.dump");
System.clearProperty("spdf.pdfjson.repeatScan");
// a non-existent path must not be read because all flags are off
java.nio.file.Path missing = java.nio.file.Path.of("does-not-exist-123.json");
invoke(
"logJsonResponse",
new Class<?>[] {String.class, java.nio.file.Path.class},
"label",
missing);
} finally {
if (dumpPrev != null) System.setProperty("spdf.pdfjson.dump", dumpPrev);
if (scanPrev != null) System.setProperty("spdf.pdfjson.repeatScan", scanPrev);
}
}
}
@Nested
@DisplayName("GET endpoint job-access guard")
class JobAccessGuard {
@Test
@DisplayName("extractSinglePage rejects an unauthorized job before doing work")
void singlePageRejected() {
doThrow(new SecurityException("denied"))
.when(jobOwnershipService)
.validateJobAccess("bad");
assertThrows(SecurityException.class, () -> controller.extractSinglePage("bad", 1));
verifyNoConversion();
}
@Test
@DisplayName("extractPageFonts rejects an unauthorized job before doing work")
void pageFontsRejected() {
doThrow(new SecurityException("denied"))
.when(jobOwnershipService)
.validateJobAccess("bad");
assertThrows(SecurityException.class, () -> controller.extractPageFonts("bad", 2));
verifyNoConversion();
}
@Test
@DisplayName("exportPartialPdf rejects an unauthorized job before doing work")
void exportPartialRejected() {
when(jobOwnershipService.validateJobAccess(anyString()))
.thenThrow(new SecurityException("denied"));
assertThrows(
SecurityException.class,
() ->
controller.exportPartialPdf(
"bad",
new stirling.software.SPDF.model.json.PdfJsonDocument(),
"out.pdf"));
}
private void verifyNoConversion() {
try {
verify(pdfJsonConversionService, never())
.extractSinglePage(
anyString(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.any());
verify(pdfJsonConversionService, never())
.extractPageFonts(
anyString(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.any());
} catch (Exception e) {
throw new AssertionError(e);
}
}
}
}
@@ -0,0 +1,606 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.json.PdfJsonDocument;
import stirling.software.SPDF.model.json.PdfJsonMetadata;
import stirling.software.SPDF.service.PdfJsonConversionService;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ConvertPdfJsonController additional branch coverage")
class ConvertPdfJsonControllerMoreTest {
@Mock private PdfJsonConversionService pdfJsonConversionService;
@Mock private TempFileManager tempFileManager;
@Mock private JobOwnershipService jobOwnershipService;
@InjectMocks private ConvertPdfJsonController controller;
private final java.util.List<TempFile> createdTempFiles = new java.util.ArrayList<>();
@BeforeEach
void setUp() throws Exception {
// @InjectMocks uses the @RequiredArgsConstructor, so the @Autowired field is not
// auto-injected; wire the JobOwnershipService mock in by reflection.
setJobOwnershipService(jobOwnershipService);
when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("more-test", inv.<String>getArgument(0))
.toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
createdTempFiles.add(tf);
return tf;
});
}
private void setJobOwnershipService(JobOwnershipService service) throws Exception {
Field f = ConvertPdfJsonController.class.getDeclaredField("jobOwnershipService");
f.setAccessible(true);
f.set(controller, service);
}
@AfterEach
void tearDown() {
for (TempFile tf : createdTempFiles) {
try {
if (tf.getFile() != null) {
tf.getFile().delete();
}
} catch (Exception ignored) {
// best-effort cleanup
}
}
createdTempFiles.clear();
}
private static byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (java.io.InputStream in = response.getBody().getInputStream()) {
in.transferTo(baos);
}
return baos.toByteArray();
}
// Disable the @Autowired(required=false) JobOwnershipService for no-auth code paths.
private void clearJobOwnershipService() throws Exception {
setJobOwnershipService(null);
}
@Nested
@DisplayName("convertPdfToJson filename handling")
class ConvertPdfToJsonFilename {
@Test
@DisplayName("Null original filename falls back to document.json")
void nullOriginalFilename() throws Exception {
MockMultipartFile pdfFile =
new MockMultipartFile("fileInput", null, "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
doAnswer(writeBytes(2, "{}".getBytes()))
.when(pdfJsonConversionService)
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
assertEquals(HttpStatus.OK, response.getStatusCode());
String cd = response.getHeaders().getFirst("Content-Disposition");
assertThat(cd).contains("document.json");
}
@Test
@DisplayName("Blank original filename falls back to document.json")
void blankOriginalFilename() throws Exception {
MockMultipartFile pdfFile =
new MockMultipartFile("fileInput", " ", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
doAnswer(writeBytes(2, "{}".getBytes()))
.when(pdfJsonConversionService)
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
assertThat(response.getHeaders().getFirst("Content-Disposition"))
.contains("document.json");
}
@Test
@DisplayName("Named file strips extension and appends .json")
void namedFileStripsExtension() throws Exception {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "report.final.pdf", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
doAnswer(writeBytes(2, "{}".getBytes()))
.when(pdfJsonConversionService)
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
assertThat(response.getHeaders().getFirst("Content-Disposition"))
.contains("report.final.json");
}
@Test
@DisplayName("Service exception closes temp file and propagates")
void serviceExceptionClosesTempFile() throws Exception {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
doThrow(new IllegalStateException("boom"))
.when(pdfJsonConversionService)
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
assertThrows(
IllegalStateException.class, () -> controller.convertPdfToJson(request, false));
verify(createdTempFiles.get(0)).close();
}
}
@Nested
@DisplayName("convertJsonToPdf filename handling")
class ConvertJsonToPdfFilename {
@Test
@DisplayName("Null original filename falls back to document.pdf")
void nullOriginalFilename() throws Exception {
MockMultipartFile jsonFile =
new MockMultipartFile("fileInput", null, "application/json", "{}".getBytes());
GeneralFile request = new GeneralFile();
request.setFileInput(jsonFile);
doAnswer(writeBytes(1, "pdf".getBytes()))
.when(pdfJsonConversionService)
.convertJsonToPdf(eq(jsonFile), any(OutputStream.class));
ResponseEntity<Resource> response = controller.convertJsonToPdf(request);
assertThat(response.getHeaders().getFirst("Content-Disposition"))
.contains("document.pdf");
}
@Test
@DisplayName("Filename already ending in .pdf is preserved")
void filenameAlreadyPdf() throws Exception {
// toSimpleFileName keeps base name; extension pattern strips the trailing .pdf,
// so a base name that itself ends in .pdf exercises the endsWith branch.
MockMultipartFile jsonFile =
new MockMultipartFile(
"fileInput", "weird.pdf.json", "application/json", "{}".getBytes());
GeneralFile request = new GeneralFile();
request.setFileInput(jsonFile);
doAnswer(writeBytes(1, "pdf".getBytes()))
.when(pdfJsonConversionService)
.convertJsonToPdf(eq(jsonFile), any(OutputStream.class));
ResponseEntity<Resource> response = controller.convertJsonToPdf(request);
assertThat(response.getHeaders().getFirst("Content-Disposition")).contains("weird.pdf");
}
@Test
@DisplayName("Service exception closes temp file and propagates")
void serviceExceptionClosesTempFile() throws Exception {
MockMultipartFile jsonFile =
new MockMultipartFile(
"fileInput", "doc.json", "application/json", "{}".getBytes());
GeneralFile request = new GeneralFile();
request.setFileInput(jsonFile);
doThrow(new IllegalStateException("boom"))
.when(pdfJsonConversionService)
.convertJsonToPdf(eq(jsonFile), any(OutputStream.class));
assertThrows(IllegalStateException.class, () -> controller.convertJsonToPdf(request));
verify(createdTempFiles.get(0)).close();
}
}
@Nested
@DisplayName("extractPdfMetadata job-key scoping")
class ExtractMetadataScoping {
@Test
@DisplayName("Uses scoped job key when JobOwnershipService present")
void usesScopedKey() throws Exception {
when(jobOwnershipService.createScopedJobKey(anyString())).thenReturn("user:scoped-id");
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
doAnswer(writeBytes(2, "{}".getBytes()))
.when(pdfJsonConversionService)
.extractDocumentMetadata(
eq(pdfFile), eq("user:scoped-id"), any(OutputStream.class));
ResponseEntity<Resource> response = controller.extractPdfMetadata(request);
assertEquals("user:scoped-id", response.getHeaders().getFirst("X-Job-Id"));
verify(jobOwnershipService).createScopedJobKey(anyString());
}
@Test
@DisplayName("Uses raw job key when no JobOwnershipService")
void usesRawKeyWhenNoService() throws Exception {
clearJobOwnershipService();
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
doAnswer(writeBytes(2, "{}".getBytes()))
.when(pdfJsonConversionService)
.extractDocumentMetadata(eq(pdfFile), anyString(), any(OutputStream.class));
ResponseEntity<Resource> response = controller.extractPdfMetadata(request);
assertNotNull(response.getHeaders().getFirst("X-Job-Id"));
}
@Test
@DisplayName("Service exception closes temp file and propagates")
void serviceExceptionClosesTempFile() throws Exception {
when(jobOwnershipService.createScopedJobKey(anyString())).thenReturn("k");
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
doThrow(new IllegalStateException("boom"))
.when(pdfJsonConversionService)
.extractDocumentMetadata(eq(pdfFile), anyString(), any(OutputStream.class));
assertThrows(IllegalStateException.class, () -> controller.extractPdfMetadata(request));
verify(createdTempFiles.get(0)).close();
}
}
@Nested
@DisplayName("exportPartialPdf")
class ExportPartialPdf {
@Test
@DisplayName("Null document throws")
void nullDocumentThrows() {
assertThrows(
Exception.class, () -> controller.exportPartialPdf("job", null, "out.pdf"));
}
@Test
@DisplayName("Explicit filename param wins over metadata title")
void filenameParamWins() throws Exception {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
PdfJsonDocument doc = new PdfJsonDocument();
doc.setMetadata(PdfJsonMetadata.builder().title("titleName").build());
doAnswer(writeBytes(2, "pdf".getBytes()))
.when(pdfJsonConversionService)
.exportUpdatedPages(eq("job"), eq(doc), any(OutputStream.class));
ResponseEntity<Resource> response =
controller.exportPartialPdf("job", doc, "custom.pdf");
assertThat(response.getHeaders().getFirst("Content-Disposition"))
.contains("custom.pdf");
}
@Test
@DisplayName("Falls back to metadata title when filename blank")
void fallbackToMetadataTitle() throws Exception {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
PdfJsonDocument doc = new PdfJsonDocument();
doc.setMetadata(PdfJsonMetadata.builder().title("MyTitle").build());
doAnswer(writeBytes(2, "pdf".getBytes()))
.when(pdfJsonConversionService)
.exportUpdatedPages(eq("job"), eq(doc), any(OutputStream.class));
ResponseEntity<Resource> response = controller.exportPartialPdf("job", doc, " ");
assertThat(response.getHeaders().getFirst("Content-Disposition"))
.contains("MyTitle.pdf");
}
@Test
@DisplayName("Falls back to document when title null/blank and no filename")
void fallbackToDocumentName() throws Exception {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
PdfJsonDocument doc = new PdfJsonDocument();
doc.setMetadata(PdfJsonMetadata.builder().title(" ").build());
doAnswer(writeBytes(2, "pdf".getBytes()))
.when(pdfJsonConversionService)
.exportUpdatedPages(eq("job"), eq(doc), any(OutputStream.class));
ResponseEntity<Resource> response = controller.exportPartialPdf("job", doc, null);
assertThat(response.getHeaders().getFirst("Content-Disposition"))
.contains("document.pdf");
}
@Test
@DisplayName("Null metadata falls back to document name")
void nullMetadataFallback() throws Exception {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
PdfJsonDocument doc = new PdfJsonDocument();
doc.setMetadata(null);
doAnswer(writeBytes(2, "pdf".getBytes()))
.when(pdfJsonConversionService)
.exportUpdatedPages(eq("job"), eq(doc), any(OutputStream.class));
ResponseEntity<Resource> response = controller.exportPartialPdf("job", doc, null);
assertThat(response.getHeaders().getFirst("Content-Disposition"))
.contains("document.pdf");
}
@Test
@DisplayName("Service exception closes temp file and propagates")
void serviceExceptionClosesTempFile() throws Exception {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
PdfJsonDocument doc = new PdfJsonDocument();
doThrow(new IllegalStateException("boom"))
.when(pdfJsonConversionService)
.exportUpdatedPages(
anyString(), any(PdfJsonDocument.class), any(OutputStream.class));
assertThrows(
IllegalStateException.class,
() -> controller.exportPartialPdf("job", doc, "out.pdf"));
verify(createdTempFiles.get(0)).close();
}
}
@Nested
@DisplayName("extractSinglePage and extractPageFonts errors")
class GetPageErrors {
@Test
@DisplayName("extractSinglePage service exception closes temp file")
void singlePageException() throws Exception {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
doThrow(new IllegalStateException("boom"))
.when(pdfJsonConversionService)
.extractSinglePage(anyString(), anyInt(), any(OutputStream.class));
assertThrows(IllegalStateException.class, () -> controller.extractSinglePage("job", 1));
verify(createdTempFiles.get(0)).close();
}
@Test
@DisplayName("extractPageFonts service exception closes temp file")
void pageFontsException() throws Exception {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
doThrow(new IllegalStateException("boom"))
.when(pdfJsonConversionService)
.extractPageFonts(anyString(), anyInt(), any(OutputStream.class));
assertThrows(IllegalStateException.class, () -> controller.extractPageFonts("job", 1));
verify(createdTempFiles.get(0)).close();
}
@Test
@DisplayName("Page docName carries page number")
void singlePageDocName() throws Exception {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
doAnswer(writeBytes(2, "{}".getBytes()))
.when(pdfJsonConversionService)
.extractSinglePage(eq("job"), eq(7), any(OutputStream.class));
ResponseEntity<Resource> response = controller.extractSinglePage("job", 7);
assertThat(response.getHeaders().getFirst("Content-Disposition"))
.contains("page_7.json");
}
}
@Nested
@DisplayName("validateJobAccess and clearCache delegation")
class JobAccessDelegation {
@Test
@DisplayName("clearCache validates and delegates when service present")
void clearCacheWithService() {
when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true);
ResponseEntity<Void> response = controller.clearCache("job-1");
assertEquals(HttpStatus.OK, response.getStatusCode());
verify(jobOwnershipService).validateJobAccess("job-1");
verify(pdfJsonConversionService).clearCachedDocument("job-1");
}
@Test
@DisplayName("clearCache skips validation when no service")
void clearCacheNoService() throws Exception {
clearJobOwnershipService();
ResponseEntity<Void> response = controller.clearCache("job-2");
assertEquals(HttpStatus.OK, response.getStatusCode());
verify(pdfJsonConversionService).clearCachedDocument("job-2");
verify(jobOwnershipService, never()).validateJobAccess(anyString());
}
@Test
@DisplayName("validateJobAccess propagates SecurityException from service")
void validateThrows() {
doThrow(new SecurityException("denied"))
.when(jobOwnershipService)
.validateJobAccess("bad");
assertThrows(SecurityException.class, () -> controller.clearCache("bad"));
verify(pdfJsonConversionService, never()).clearCachedDocument(anyString());
}
}
@Nested
@DisplayName("logJsonResponse diagnostic paths")
class LogJsonResponseDiagnostics {
@Test
@DisplayName("Debug dump writes a copy to configured dir")
void debugDumpWritesCopy(@org.junit.jupiter.api.io.TempDir Path dumpDir) throws Exception {
String previous = System.getProperty("spdf.pdfjson.dump");
System.setProperty("spdf.pdfjson.dump", "true");
String prevDir = System.getProperty("java.io.tmpdir");
// SPDF_PDFJSON_DUMP_DIR env may be unset; controller falls back to java.io.tmpdir.
System.setProperty("java.io.tmpdir", dumpDir.toString());
try {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
doAnswer(writeBytes(2, "{\"k\":\"value-string-here\"}".getBytes()))
.when(pdfJsonConversionService)
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
assertEquals(HttpStatus.OK, response.getStatusCode());
try (var stream = Files.list(dumpDir)) {
boolean dumped =
stream.anyMatch(p -> p.getFileName().toString().startsWith("pdfjson_"));
assertThat(dumped).isTrue();
}
} finally {
restoreProp("spdf.pdfjson.dump", previous);
restoreProp("java.io.tmpdir", prevDir);
}
}
@Test
@DisplayName("Repeat scan runs without error on repeated strings")
void repeatScanRuns() throws Exception {
String previous = System.getProperty("spdf.pdfjson.repeatScan");
System.setProperty("spdf.pdfjson.repeatScan", "true");
try {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
// Two repeated >=12 char strings plus a long base64-like one to exercise filters.
String json =
"{\"a\":\"repeated-string-value\",\"b\":\"repeated-string-value\","
+ "\"c\":\"QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2Nzg5\"}";
doAnswer(writeBytes(2, json.getBytes()))
.when(pdfJsonConversionService)
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertThat(drainBody(response)).isNotEmpty();
} finally {
restoreProp("spdf.pdfjson.repeatScan", previous);
}
}
@Test
@DisplayName("Repeat scan handles no repeated strings")
void repeatScanNoRepeats() throws Exception {
String previous = System.getProperty("spdf.pdfjson.repeatScan");
System.setProperty("spdf.pdfjson.repeatScan", "true");
try {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "x".getBytes());
PDFFile request = new PDFFile();
request.setFileInput(pdfFile);
String json = "{\"only-key-here-unique\":\"only-value-here-unique\"}";
doAnswer(writeBytes(2, json.getBytes()))
.when(pdfJsonConversionService)
.convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class));
ResponseEntity<Resource> response = controller.convertPdfToJson(request, false);
assertEquals(HttpStatus.OK, response.getStatusCode());
} finally {
restoreProp("spdf.pdfjson.repeatScan", previous);
}
}
}
private static org.mockito.stubbing.Answer<Object> writeBytes(int argIndex, byte[] data) {
return inv -> {
OutputStream os = inv.getArgument(argIndex, OutputStream.class);
os.write(data);
return null;
};
}
private static void restoreProp(String key, String previous) {
if (previous == null) {
System.clearProperty(key);
} else {
System.setProperty(key, previous);
}
}
}
@@ -0,0 +1,103 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import stirling.software.SPDF.exception.CacheUnavailableException;
import tools.jackson.databind.ObjectMapper;
/**
* Unit tests for {@link ConvertPdfJsonExceptionHandler}. A real ObjectMapper exercises the happy
* path; a throwing mock drives the serialization-failure fallback branches.
*/
@DisplayName("ConvertPdfJsonExceptionHandler")
class ConvertPdfJsonExceptionHandlerTest {
@Nested
@DisplayName("handleCacheUnavailable - success")
class Success {
@Test
@DisplayName("serializes a 410 GONE JSON body with the error details")
void serializesGoneResponse() {
ConvertPdfJsonExceptionHandler handler =
new ConvertPdfJsonExceptionHandler(new ObjectMapper());
ResponseEntity<byte[]> response =
handler.handleCacheUnavailable(new CacheUnavailableException("cache is gone"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GONE);
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.APPLICATION_JSON);
String json = new String(response.getBody());
assertThat(json).contains("cache_unavailable");
assertThat(json).contains("reupload");
assertThat(json).contains("cache is gone");
}
@Test
@DisplayName("tolerates a null exception message")
void toleratesNullMessage() {
ConvertPdfJsonExceptionHandler handler =
new ConvertPdfJsonExceptionHandler(new ObjectMapper());
ResponseEntity<byte[]> response =
handler.handleCacheUnavailable(new CacheUnavailableException(null));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GONE);
assertThat(response.getBody()).isNotEmpty();
}
}
@Nested
@DisplayName("handleCacheUnavailable - fallback")
class Fallback {
@Test
@DisplayName("uses the literal JSON fallback when every serialization attempt fails")
void literalFallbackWhenAllSerializationFails() {
ObjectMapper mapper = Mockito.mock(ObjectMapper.class);
// Both the primary and the secondary writeValueAsBytes calls fail.
Mockito.when(mapper.writeValueAsBytes(any())).thenThrow(new RuntimeException("boom"));
ConvertPdfJsonExceptionHandler handler = new ConvertPdfJsonExceptionHandler(mapper);
ResponseEntity<byte[]> response =
handler.handleCacheUnavailable(new CacheUnavailableException("nope"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GONE);
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.APPLICATION_JSON);
// Last-ditch hand-written JSON literal.
String json = new String(response.getBody());
assertThat(json).contains("cache_unavailable");
assertThat(json).contains("Cache unavailable");
}
@Test
@DisplayName("recovers via the second serialization attempt when only the first fails")
void secondAttemptSucceeds() {
ObjectMapper mapper = Mockito.mock(ObjectMapper.class);
byte[] fallbackJson = "{\"error\":\"cache_unavailable\"}".getBytes();
// First call throws, second returns serialized bytes.
Mockito.when(mapper.writeValueAsBytes(any()))
.thenThrow(new RuntimeException("first fails"))
.thenReturn(fallbackJson);
ConvertPdfJsonExceptionHandler handler = new ConvertPdfJsonExceptionHandler(mapper);
ResponseEntity<byte[]> response =
handler.handleCacheUnavailable(new CacheUnavailableException("retry"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GONE);
assertThat(response.getBody()).isEqualTo(fallbackJson);
}
}
}
@@ -0,0 +1,214 @@
package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.converters.SvgToPdfRequest;
import stirling.software.SPDF.utils.SvgToPdf;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.SvgSanitizer;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
/**
* Gap coverage for {@link ConvertSvgToPDF}: the multi-file zip path and the empty-output failure
* branches not covered by ConvertSvgToPDFTest.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ConvertSvgToPDF zip and failure branches")
class ConvertSvgToPDFMoreTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private SvgSanitizer svgSanitizer;
@Mock private TempFileManager tempFileManager;
@InjectMocks private ConvertSvgToPDF controller;
@BeforeEach
void setUp() throws Exception {
// Real backing files so the zip/pdf streams can actually be written.
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("svg", inv.<String>getArgument(0))
.toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
}
private static MockMultipartFile svg(String name, String content) {
return new MockMultipartFile("fileInput", name, "image/svg+xml", content.getBytes());
}
private static List<String> zipNames(Resource resource) throws Exception {
List<String> names = new ArrayList<>();
try (ZipInputStream zis =
new ZipInputStream(new ByteArrayInputStream(resource.getContentAsByteArray()))) {
ZipEntry e;
while ((e = zis.getNextEntry()) != null) {
names.add(e.getName());
zis.closeEntry();
}
}
return names;
}
@Test
@DisplayName("multiple SVGs in separate mode are returned as a zip")
void multipleSeparateZipsOutput() throws Exception {
byte[] sanitized1 = "<svg>a</svg>".getBytes();
byte[] sanitized2 = "<svg>b</svg>".getBytes();
byte[] pdf1 = "pdf1".getBytes();
byte[] pdf2 = "pdf2".getBytes();
SvgToPdfRequest request = new SvgToPdfRequest();
request.setFileInput(
new MockMultipartFile[] {
svg("a.svg", "<svg>1</svg>"), svg("b.svg", "<svg>2</svg>")
});
request.setCombineIntoSinglePdf(false);
when(svgSanitizer.sanitize("<svg>1</svg>".getBytes())).thenReturn(sanitized1);
when(svgSanitizer.sanitize("<svg>2</svg>".getBytes())).thenReturn(sanitized2);
when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdf1)).thenReturn(pdf1);
when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdf2)).thenReturn(pdf2);
try (MockedStatic<SvgToPdf> svg = Mockito.mockStatic(SvgToPdf.class)) {
svg.when(() -> SvgToPdf.convert(sanitized1)).thenReturn(pdf1);
svg.when(() -> SvgToPdf.convert(sanitized2)).thenReturn(pdf2);
ResponseEntity<Resource> response = controller.convertSvgToPdf(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<String> names = zipNames(response.getBody());
assertEquals(2, names.size());
}
}
@Nested
@DisplayName("empty-output failures")
class EmptyOutputs {
@Test
@DisplayName("combined mode returns 500 when the combined PDF is empty")
void combinedEmptyOutput() throws Exception {
byte[] sanitized = "<svg>s</svg>".getBytes();
SvgToPdfRequest request = new SvgToPdfRequest();
request.setFileInput(new MockMultipartFile[] {svg("a.svg", "<svg>1</svg>")});
request.setCombineIntoSinglePdf(true);
when(svgSanitizer.sanitize("<svg>1</svg>".getBytes())).thenReturn(sanitized);
try (MockedStatic<SvgToPdf> svg = Mockito.mockStatic(SvgToPdf.class)) {
svg.when(() -> SvgToPdf.combineIntoPdf(any())).thenReturn(new byte[0]);
ResponseEntity<Resource> response = controller.convertSvgToPdf(request);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
}
}
@Test
@DisplayName("separate mode returns 500 when no file converts successfully")
void separateAllEmpty() throws Exception {
byte[] sanitized = "<svg>s</svg>".getBytes();
SvgToPdfRequest request = new SvgToPdfRequest();
request.setFileInput(new MockMultipartFile[] {svg("a.svg", "<svg>1</svg>")});
request.setCombineIntoSinglePdf(false);
when(svgSanitizer.sanitize("<svg>1</svg>".getBytes())).thenReturn(sanitized);
try (MockedStatic<SvgToPdf> svg = Mockito.mockStatic(SvgToPdf.class)) {
// Empty conversion output -> the file is skipped -> no successful conversions.
svg.when(() -> SvgToPdf.convert(sanitized)).thenReturn(new byte[0]);
ResponseEntity<Resource> response = controller.convertSvgToPdf(request);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
}
}
@Test
@DisplayName("combined mode returns 500 when conversion throws IOException")
void combinedConversionThrows() throws Exception {
byte[] sanitized = "<svg>s</svg>".getBytes();
SvgToPdfRequest request = new SvgToPdfRequest();
request.setFileInput(new MockMultipartFile[] {svg("a.svg", "<svg>1</svg>")});
request.setCombineIntoSinglePdf(true);
when(svgSanitizer.sanitize("<svg>1</svg>".getBytes())).thenReturn(sanitized);
try (MockedStatic<SvgToPdf> svg = Mockito.mockStatic(SvgToPdf.class)) {
svg.when(() -> SvgToPdf.combineIntoPdf(any()))
.thenThrow(new IOException("convert failure"));
ResponseEntity<Resource> response = controller.convertSvgToPdf(request);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
}
}
}
@Test
@DisplayName("a single converted SVG uses the pdf-file response path")
void singleConvertedUsesPdfResponse() throws Exception {
byte[] sanitized = "<svg>s</svg>".getBytes();
byte[] pdf = "pdf".getBytes();
SvgToPdfRequest request = new SvgToPdfRequest();
request.setFileInput(new MockMultipartFile[] {svg("only.svg", "<svg>1</svg>")});
request.setCombineIntoSinglePdf(false);
when(svgSanitizer.sanitize("<svg>1</svg>".getBytes())).thenReturn(sanitized);
when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdf)).thenReturn(pdf);
ResponseEntity<Resource> stub =
ResponseEntity.ok(new org.springframework.core.io.ByteArrayResource(pdf));
try (MockedStatic<SvgToPdf> svg = Mockito.mockStatic(SvgToPdf.class);
MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class)) {
svg.when(() -> SvgToPdf.convert(sanitized)).thenReturn(pdf);
wr.when(() -> WebResponseUtils.pdfFileToWebResponse(any(TempFile.class), anyString()))
.thenReturn(stub);
ResponseEntity<Resource> response = controller.convertSvgToPdf(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
}
@@ -0,0 +1,203 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager;
/**
* Branch coverage for the disallowed-scheme detection helpers of {@link ConvertWebsiteToPDF}. These
* are pure string transforms exercised by reflection, so no network call or external WeasyPrint
* invocation occurs.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ConvertWebsiteToPDF scheme-detection helpers")
class ConvertWebsiteToPDFExtraTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private TempFileManager tempFileManager;
private ConvertWebsiteToPDF sut;
@BeforeEach
void setUp() {
sut =
new ConvertWebsiteToPDF(
pdfDocumentFactory,
runtimePathConfig,
new ApplicationProperties(),
tempFileManager);
}
private boolean containsDisallowed(String html) throws Exception {
Method m =
ConvertWebsiteToPDF.class.getDeclaredMethod(
"containsDisallowedUriScheme", String.class);
m.setAccessible(true);
return (boolean) m.invoke(sut, html);
}
private String percentDecode(String content) throws Exception {
Method m = ConvertWebsiteToPDF.class.getDeclaredMethod("percentDecode", String.class);
m.setAccessible(true);
return (String) m.invoke(sut, content);
}
private String decodeEntities(String content) throws Exception {
Method m =
ConvertWebsiteToPDF.class.getDeclaredMethod(
"decodeNumericHtmlEntities", String.class);
m.setAccessible(true);
return (String) m.invoke(sut, content);
}
@Nested
@DisplayName("containsDisallowedUriScheme")
class DisallowedScheme {
@Test
@DisplayName("null and empty content are allowed")
void nullAndEmpty() throws Exception {
assertThat(containsDisallowed(null)).isFalse();
assertThat(containsDisallowed("")).isFalse();
}
@Test
@DisplayName("plain safe html is allowed")
void safeHtml() throws Exception {
assertThat(
containsDisallowed(
"<html><body><a href=\"https://x.com\">ok</a></body></html>"))
.isFalse();
}
@Test
@DisplayName("a literal file:/// scheme is rejected")
void literalFileScheme() throws Exception {
assertThat(containsDisallowed("<a href=\"file:///etc/passwd\">x</a>")).isTrue();
}
@Test
@DisplayName("an uppercase FILE: scheme is rejected after lower-casing")
void uppercaseFileScheme() throws Exception {
assertThat(containsDisallowed("<a href=\"FILE://server/share\">x</a>")).isTrue();
}
@Test
@DisplayName("a percent-encoded file scheme separator is rejected")
void percentEncodedSeparator() throws Exception {
// file:%2f%2f decodes to file:// during normalization
assertThat(containsDisallowed("<a href=\"file:%2f%2fetc/passwd\">x</a>")).isTrue();
}
@Test
@DisplayName("an html-entity encoded slash sequence is rejected")
void htmlEntitySlashes() throws Exception {
// file:&#47;&#47; -> file:// after numeric-entity decoding
assertThat(containsDisallowed("<a href=\"file:&#47;&#47;etc\">x</a>")).isTrue();
}
@Test
@DisplayName("a named-entity colon/slash sequence is rejected")
void namedEntitySlashes() throws Exception {
// file&colon;&sol;&sol; -> file:// after named-entity replacement
assertThat(containsDisallowed("<a href=\"file&colon;&sol;&sol;etc\">x</a>")).isTrue();
}
@Test
@DisplayName("the word 'profile:' is not mistaken for a file scheme")
void wordBoundaryGuard() throws Exception {
// the (?<![a-z0-9_]) lookbehind prevents matching the 'file' inside 'profile'
assertThat(containsDisallowed("<span>profile://something</span>")).isFalse();
}
}
@Nested
@DisplayName("percentDecode")
class PercentDecode {
@Test
@DisplayName("decodes valid percent escapes")
void decodesValid() throws Exception {
assertThat(percentDecode("a%2fb")).isEqualTo("a/b");
}
@Test
@DisplayName("leaves a trailing incomplete escape untouched")
void trailingIncomplete() throws Exception {
// not enough trailing chars for a full %XX -> appended literally
assertThat(percentDecode("end%2")).isEqualTo("end%2");
}
@Test
@DisplayName("leaves a non-hex escape untouched")
void nonHexEscape() throws Exception {
assertThat(percentDecode("a%zzb")).isEqualTo("a%zzb");
}
@Test
@DisplayName("content with no escapes is returned unchanged")
void noEscapes() throws Exception {
assertThat(percentDecode("plain text")).isEqualTo("plain text");
}
}
@Nested
@DisplayName("decodeNumericHtmlEntities")
class DecodeNumericHtmlEntities {
@Test
@DisplayName("decodes a decimal numeric entity")
void decimalEntity() throws Exception {
// &#47; is '/'
assertThat(decodeEntities("a&#47;b")).isEqualTo("a/b");
}
@Test
@DisplayName("decodes a hexadecimal numeric entity")
void hexEntity() throws Exception {
// &#x2f; is '/'
assertThat(decodeEntities("a&#x2f;b")).isEqualTo("a/b");
}
@Test
@DisplayName("content without entities is unchanged")
void noEntities() throws Exception {
assertThat(decodeEntities("nothing here")).isEqualTo("nothing here");
}
}
@Test
@DisplayName("controller is constructed with its collaborators")
void constructed() {
assertThat(sut).isNotNull();
}
@Test
@DisplayName("reflection invocation surfaces are wired correctly")
void reflectionWired() throws Exception {
// guards against a refactor renaming the private helpers used above
try {
assertThat(containsDisallowed("safe")).isFalse();
} catch (InvocationTargetException e) {
throw new AssertionError("helper invocation failed", e.getCause());
}
}
}
@@ -0,0 +1,214 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.SPDF.pdf.parser.PdfModels.Bounds;
import stirling.software.SPDF.pdf.parser.PdfModels.TableFragment;
import stirling.software.SPDF.pdf.parser.TabulaTableParser;
import stirling.software.common.service.CustomPDFDocumentFactory;
/**
* Additional coverage for {@link ExtractCSVController}. The Tabula parser is mocked so
* deterministic table fragments drive the single-table, multi-table and no-table response branches;
* documents are built in-memory.
*/
@ExtendWith(MockitoExtension.class)
class ExtractCSVControllerMoreTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TabulaTableParser tabulaTableParser;
@InjectMocks private ExtractCSVController controller;
private static PDDocument docWithPages(int pages) {
PDDocument doc = new PDDocument();
for (int i = 0; i < pages; i++) {
doc.addPage(new PDPage());
}
return doc;
}
private static MockMultipartFile pdf(String name) {
return new MockMultipartFile(
"fileInput", name, MediaType.APPLICATION_PDF_VALUE, "pdf".getBytes());
}
/** Build a TableFragment whose only meaningful payload for CSV output is rawRows. */
private static TableFragment fragment(List<List<String>> rawRows) {
return new TableFragment(
"tbl",
1,
new Bounds(0f, 0f, 100f, 100f),
List.of(),
List.of(),
rawRows,
rawRows.isEmpty() ? 0 : rawRows.get(0).size(),
1.0f,
List.of(),
null);
}
@Nested
@DisplayName("response shape by table count")
class ResponseShape {
@Test
@DisplayName("returns no content when no tables are found")
void noTablesNoContent() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("data.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1));
when(tabulaTableParser.parse(any(PDDocument.class), eq(1))).thenReturn(List.of());
ResponseEntity<?> response = controller.pdfToCsv(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
@Test
@DisplayName("returns a single CSV body when exactly one table is found")
void singleTableCsv() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("report.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1));
when(tabulaTableParser.parse(any(PDDocument.class), eq(1)))
.thenReturn(
List.of(
fragment(
List.of(
List.of("Name", "Age"),
List.of("Alice", "30")))));
ResponseEntity<?> response = controller.pdfToCsv(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType().toString()).startsWith("text/csv");
assertThat(response.getHeaders().getContentDisposition().getFilename())
.isEqualTo("report_extracted.csv");
assertThat(response.getBody().toString()).contains("Name").contains("Alice");
}
@Test
@DisplayName("returns a zip when multiple tables span multiple pages")
void multiTableZip() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("multi.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(2));
when(tabulaTableParser.parse(any(PDDocument.class), eq(1)))
.thenReturn(List.of(fragment(List.of(List.of("a", "b")))));
when(tabulaTableParser.parse(any(PDDocument.class), eq(2)))
.thenReturn(
List.of(
fragment(List.of(List.of("c", "d"))),
fragment(List.of(List.of("e", "f")))));
ResponseEntity<?> response = controller.pdfToCsv(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
assertThat(response.getHeaders().getContentDisposition().getFilename())
.isEqualTo("multi_extracted.zip");
byte[] body = (byte[]) response.getBody();
assertThat(zipEntryNames(body))
.containsExactlyInAnyOrder(
"multi_p1_t1.csv", "multi_p2_t1.csv", "multi_p2_t2.csv");
}
private List<String> zipEntryNames(byte[] zipBytes) throws Exception {
java.util.List<String> names = new java.util.ArrayList<>();
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
names.add(entry.getName());
zis.closeEntry();
}
}
return names;
}
}
@Nested
@DisplayName("error propagation")
class Errors {
@Test
@DisplayName("propagates a parser failure")
void parserFailurePropagates() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("bad.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1));
when(tabulaTableParser.parse(any(PDDocument.class), eq(1)))
.thenThrow(new java.io.IOException("parse boom"));
assertThatThrownBy(() -> controller.pdfToCsv(request))
.isInstanceOf(java.io.IOException.class);
}
@Test
@DisplayName("propagates a document load failure")
void loadFailurePropagates() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("corrupt.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenThrow(new java.io.IOException("load boom"));
assertThatThrownBy(() -> controller.pdfToCsv(request))
.isInstanceOf(java.io.IOException.class);
}
}
@Test
@DisplayName("single table CSV body is quote-wrapped per the EXCEL/QuoteMode.ALL format")
void csvBodyIsQuoted() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("q.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1));
when(tabulaTableParser.parse(any(PDDocument.class), eq(1)))
.thenReturn(List.of(fragment(List.of(List.of("x", "y")))));
ResponseEntity<?> response = controller.pdfToCsv(request);
String body = response.getBody().toString();
// QuoteMode.ALL wraps every field in double quotes.
assertThat(body).contains("\"x\"").contains("\"y\"");
assertThat(body.getBytes(StandardCharsets.UTF_8)).isNotEmpty();
}
}
@@ -0,0 +1,261 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.PdfVectorExportRequest;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
/**
* Gap coverage for {@link PdfVectorExportController#convertPdfToVector} and the Ghostscript
* PDF-to-vector helper, complementing PdfVectorExportControllerTest (which only covers the
* PostScript-to-PDF endpoint).
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("PdfVectorExportController convertPdfToVector")
class PdfVectorExportControllerMoreTest {
private final List<Path> tempPaths = new ArrayList<>();
@Mock private TempFileManager tempFileManager;
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private ProcessExecutor ghostscriptExecutor;
@InjectMocks private PdfVectorExportController controller;
// Real manager used only to mint genuine TempFile instances for the mock to hand back.
private final TempFileManager realTempFileManager =
new TempFileManager(new TempFileRegistry(), new ApplicationProperties());
private Map<ProcessExecutor.Processes, ProcessExecutor> originalExecutors;
@BeforeEach
void setup() throws Exception {
// Return a real TempFile so no Mockito when() runs re-entrantly inside the thenAnswer.
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(inv -> realTempFileManager.createManagedTempFile(inv.getArgument(0)));
lenient()
.when(tempFileManager.createTempFile(any()))
.thenAnswer(
invocation -> {
String suffix = invocation.<String>getArgument(0);
Path path =
Files.createTempFile("vec_in", suffix == null ? "" : suffix);
tempPaths.add(path);
return path.toFile();
});
Field instancesField = ProcessExecutor.class.getDeclaredField("instances");
instancesField.setAccessible(true);
@SuppressWarnings("unchecked")
Map<ProcessExecutor.Processes, ProcessExecutor> instances =
(Map<ProcessExecutor.Processes, ProcessExecutor>) instancesField.get(null);
originalExecutors = Map.copyOf(instances);
instances.clear();
instances.put(ProcessExecutor.Processes.GHOSTSCRIPT, ghostscriptExecutor);
}
@AfterEach
void tearDown() throws Exception {
Field instancesField = ProcessExecutor.class.getDeclaredField("instances");
instancesField.setAccessible(true);
@SuppressWarnings("unchecked")
Map<ProcessExecutor.Processes, ProcessExecutor> instances =
(Map<ProcessExecutor.Processes, ProcessExecutor>) instancesField.get(null);
instances.clear();
if (originalExecutors != null) {
instances.putAll(originalExecutors);
}
reset(ghostscriptExecutor, tempFileManager, endpointConfiguration);
for (Path path : tempPaths) {
Files.deleteIfExists(path);
}
tempPaths.clear();
}
private ProcessExecutorResult okResult() {
ProcessExecutorResult result = mock(ProcessExecutorResult.class);
lenient().when(result.getRc()).thenReturn(0);
lenient().when(result.getMessages()).thenReturn("");
return result;
}
private static PdfVectorExportRequest request(String outputFormat) {
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "in.pdf", MediaType.APPLICATION_PDF_VALUE, new byte[] {1});
PdfVectorExportRequest request = new PdfVectorExportRequest();
request.setFileInput(file);
request.setOutputFormat(outputFormat);
return request;
}
@Nested
@DisplayName("media types per output format")
class MediaTypes {
@Test
@DisplayName("eps output yields application/postscript")
void epsContentType() throws Exception {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
ProcessExecutorResult ok = okResult();
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
ResponseEntity<Resource> response = controller.convertPdfToVector(request("eps"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.parseMediaType("application/postscript"));
}
@Test
@DisplayName("ps output yields application/postscript")
void psContentType() throws Exception {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
ProcessExecutorResult ok = okResult();
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
ResponseEntity<Resource> response = controller.convertPdfToVector(request("ps"));
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.parseMediaType("application/postscript"));
}
@Test
@DisplayName("pcl output yields the HP-PCL media type")
void pclContentType() throws Exception {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
ProcessExecutorResult ok = okResult();
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
ResponseEntity<Resource> response = controller.convertPdfToVector(request("pcl"));
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.parseMediaType("application/vnd.hp-PCL"));
}
@Test
@DisplayName("xps output yields the MS XPS media type")
void xpsContentType() throws Exception {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
ProcessExecutorResult ok = okResult();
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
ResponseEntity<Resource> response = controller.convertPdfToVector(request("xps"));
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.parseMediaType("application/vnd.ms-xpsdocument"));
}
@Test
@DisplayName("null output format defaults to eps")
void nullOutputFormatDefaultsToEps() throws Exception {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
ProcessExecutorResult ok = okResult();
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
ResponseEntity<Resource> response = controller.convertPdfToVector(request(null));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.parseMediaType("application/postscript"));
}
@Test
@DisplayName("uppercase output format is normalized to lowercase")
void uppercaseFormatNormalized() throws Exception {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
ProcessExecutorResult ok = okResult();
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
ResponseEntity<Resource> response = controller.convertPdfToVector(request("EPS"));
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.parseMediaType("application/postscript"));
}
}
@Nested
@DisplayName("failure paths")
class Failures {
@Test
@DisplayName("disabled Ghostscript group throws a conversion exception")
void ghostscriptDisabledThrows() {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
assertThrows(Exception.class, () -> controller.convertPdfToVector(request("eps")));
}
@Test
@DisplayName("non-zero Ghostscript return code throws a conversion exception")
void nonZeroReturnCodeThrows() {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
ProcessExecutorResult bad = mock(ProcessExecutorResult.class);
lenient().when(bad.getRc()).thenReturn(1);
lenient().when(bad.getMessages()).thenReturn("some non-critical failure");
try {
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(bad);
} catch (Exception e) {
throw new RuntimeException(e);
}
assertThrows(Exception.class, () -> controller.convertPdfToVector(request("eps")));
}
@Test
@DisplayName("an unsupported output format is rejected before any device mapping")
void unsupportedFormatThrows() {
// "svg" is not in the validated set and falls through to the device-switch default.
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
assertThrows(Exception.class, () -> controller.convertPdfToVector(request("svg")));
}
}
@Test
@DisplayName("Ghostscript runs the configured command for a successful conversion")
void runsGhostscriptCommand() throws Exception {
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
ProcessExecutorResult ok = okResult();
when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok);
ResponseEntity<Resource> response = controller.convertPdfToVector(request("eps"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
org.mockito.Mockito.verify(ghostscriptExecutor).runCommandWithOutputHandling(any());
}
}
@@ -0,0 +1,350 @@
package stirling.software.SPDF.controller.api.misc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.File;
import java.nio.file.Files;
import java.util.List;
import java.util.Optional;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.controller.api.converters.ConvertPDFToPDFA;
import stirling.software.SPDF.model.api.misc.AttachmentInfo;
import stirling.software.SPDF.model.api.misc.DeleteAttachmentRequest;
import stirling.software.SPDF.model.api.misc.ExtractAttachmentsRequest;
import stirling.software.SPDF.model.api.misc.ListAttachmentsRequest;
import stirling.software.SPDF.model.api.misc.RenameAttachmentRequest;
import stirling.software.SPDF.service.AttachmentServiceInterface;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
/**
* Coverage for the AttachmentController endpoints not exercised by AttachmentControllerTest:
* extract, list, rename, delete plus their validation paths and the add-attachment validation
* branches.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("AttachmentController extract/list/rename/delete")
class AttachmentControllerMoreTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private AttachmentServiceInterface pdfAttachmentService;
@Mock private ConvertPDFToPDFA convertPDFToPDFA;
@Mock private TempFileManager tempFileManager;
private AttachmentController controller;
private PDDocument mockDocument;
@BeforeEach
void setUp() throws Exception {
controller =
new AttachmentController(
pdfDocumentFactory,
pdfAttachmentService,
convertPDFToPDFA,
tempFileManager);
mockDocument = mock(PDDocument.class);
when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("att_test", inv.<String>getArgument(0))
.toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
}
private static MockMultipartFile pdf() {
return new MockMultipartFile(
"fileInput", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, "pdf".getBytes());
}
@Nested
@DisplayName("extractAttachments")
class Extract {
@Test
@DisplayName("writes a zip when attachments are present")
void extractsToZip() throws Exception {
ExtractAttachmentsRequest request = new ExtractAttachmentsRequest();
request.setFileInput(pdf());
when(pdfDocumentFactory.load(request, true)).thenReturn(mockDocument);
when(pdfAttachmentService.extractAttachments(mockDocument))
.thenReturn(Optional.of("zip-bytes".getBytes()));
ResponseEntity<Resource> expected =
ResponseEntity.ok(new ByteArrayResource("zip-bytes".getBytes()));
try (MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class)) {
wr.when(
() ->
WebResponseUtils.zipFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(expected);
ResponseEntity<Resource> response = controller.extractAttachments(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
verify(pdfAttachmentService).extractAttachments(mockDocument);
}
}
@Test
@DisplayName("throws when no attachments are found")
void noAttachmentsThrows() throws Exception {
ExtractAttachmentsRequest request = new ExtractAttachmentsRequest();
request.setFileInput(pdf());
when(pdfDocumentFactory.load(request, true)).thenReturn(mockDocument);
when(pdfAttachmentService.extractAttachments(mockDocument))
.thenReturn(Optional.empty());
assertThrows(
IllegalArgumentException.class, () -> controller.extractAttachments(request));
}
@Test
@DisplayName("uses fileId for the output name when no upload filename is available")
void usesFileIdForName() throws Exception {
ExtractAttachmentsRequest request = new ExtractAttachmentsRequest();
request.setFileId("server-file-id");
when(pdfDocumentFactory.load(request, true)).thenReturn(mockDocument);
when(pdfAttachmentService.extractAttachments(mockDocument))
.thenReturn(Optional.of("zip".getBytes()));
ResponseEntity<Resource> expected =
ResponseEntity.ok(new ByteArrayResource("zip".getBytes()));
try (MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class)) {
wr.when(
() ->
WebResponseUtils.zipFileToWebResponse(
any(TempFile.class), anyString()))
.thenReturn(expected);
ResponseEntity<Resource> response = controller.extractAttachments(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
}
@Nested
@DisplayName("listAttachments")
class ListAttachments {
@Test
@DisplayName("returns the attachment metadata list")
void returnsList() throws Exception {
ListAttachmentsRequest request = new ListAttachmentsRequest();
request.setFileInput(pdf());
AttachmentInfo info = new AttachmentInfo();
when(pdfDocumentFactory.load(request, true)).thenReturn(mockDocument);
when(pdfAttachmentService.listAttachments(mockDocument)).thenReturn(List.of(info));
ResponseEntity<List<AttachmentInfo>> response = controller.listAttachments(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertEquals(1, response.getBody().size());
}
@Test
@DisplayName("returns an empty list when the PDF has no attachments")
void returnsEmptyList() throws Exception {
ListAttachmentsRequest request = new ListAttachmentsRequest();
request.setFileInput(pdf());
when(pdfDocumentFactory.load(request, true)).thenReturn(mockDocument);
when(pdfAttachmentService.listAttachments(mockDocument)).thenReturn(List.of());
ResponseEntity<List<AttachmentInfo>> response = controller.listAttachments(request);
assertEquals(0, response.getBody().size());
}
}
@Nested
@DisplayName("renameAttachment")
class Rename {
@Test
@DisplayName("renames and returns the updated PDF")
void renames() throws Exception {
RenameAttachmentRequest request = new RenameAttachmentRequest();
request.setFileInput(pdf());
request.setAttachmentName("old.txt");
request.setNewName("new.txt");
when(pdfDocumentFactory.load(request, false)).thenReturn(mockDocument);
when(pdfAttachmentService.renameAttachment(mockDocument, "old.txt", "new.txt"))
.thenReturn(mockDocument);
ResponseEntity<Resource> expected =
ResponseEntity.ok(new ByteArrayResource("pdf".getBytes()));
try (MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class)) {
wr.when(
() ->
WebResponseUtils.pdfDocToWebResponse(
any(PDDocument.class),
anyString(),
any(TempFileManager.class)))
.thenReturn(expected);
ResponseEntity<Resource> response = controller.renameAttachment(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
verify(pdfAttachmentService).renameAttachment(mockDocument, "old.txt", "new.txt");
}
}
@Test
@DisplayName("rejects a blank attachment name")
void blankAttachmentNameThrows() {
RenameAttachmentRequest request = new RenameAttachmentRequest();
request.setFileInput(pdf());
request.setAttachmentName(" ");
request.setNewName("new.txt");
assertThrows(
IllegalArgumentException.class, () -> controller.renameAttachment(request));
verifyNoInteractions(pdfAttachmentService);
}
@Test
@DisplayName("rejects a null new name")
void nullNewNameThrows() {
RenameAttachmentRequest request = new RenameAttachmentRequest();
request.setFileInput(pdf());
request.setAttachmentName("old.txt");
request.setNewName(null);
assertThrows(
IllegalArgumentException.class, () -> controller.renameAttachment(request));
}
}
@Nested
@DisplayName("deleteAttachment")
class Delete {
@Test
@DisplayName("deletes and returns the updated PDF")
void deletes() throws Exception {
DeleteAttachmentRequest request = new DeleteAttachmentRequest();
request.setFileInput(pdf());
request.setAttachmentName("file.txt");
when(pdfDocumentFactory.load(request, false)).thenReturn(mockDocument);
when(pdfAttachmentService.deleteAttachment(mockDocument, "file.txt"))
.thenReturn(mockDocument);
ResponseEntity<Resource> expected =
ResponseEntity.ok(new ByteArrayResource("pdf".getBytes()));
try (MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class)) {
wr.when(
() ->
WebResponseUtils.pdfDocToWebResponse(
any(PDDocument.class),
anyString(),
any(TempFileManager.class)))
.thenReturn(expected);
ResponseEntity<Resource> response = controller.deleteAttachment(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
verify(pdfAttachmentService).deleteAttachment(mockDocument, "file.txt");
}
}
@Test
@DisplayName("rejects a null attachment name")
void nullAttachmentNameThrows() {
DeleteAttachmentRequest request = new DeleteAttachmentRequest();
request.setFileInput(pdf());
request.setAttachmentName(null);
assertThrows(
IllegalArgumentException.class, () -> controller.deleteAttachment(request));
verifyNoInteractions(pdfAttachmentService);
}
}
@Nested
@DisplayName("addAttachments validation")
class AddValidation {
@Test
@DisplayName("rejects a null attachment list")
void nullAttachmentsThrows() {
stirling.software.SPDF.model.api.misc.AddAttachmentRequest request =
new stirling.software.SPDF.model.api.misc.AddAttachmentRequest();
request.setFileInput(pdf());
request.setAttachments(null);
assertThrows(IllegalArgumentException.class, () -> controller.addAttachments(request));
}
@Test
@DisplayName("rejects an empty attachment list")
void emptyAttachmentsThrows() {
stirling.software.SPDF.model.api.misc.AddAttachmentRequest request =
new stirling.software.SPDF.model.api.misc.AddAttachmentRequest();
request.setFileInput(pdf());
request.setAttachments(List.of());
assertThrows(IllegalArgumentException.class, () -> controller.addAttachments(request));
}
@Test
@DisplayName("rejects an empty attachment entry")
void emptyAttachmentEntryThrows() {
MultipartFile empty =
new MockMultipartFile("attachment", "empty.txt", "text/plain", new byte[0]);
stirling.software.SPDF.model.api.misc.AddAttachmentRequest request =
new stirling.software.SPDF.model.api.misc.AddAttachmentRequest();
request.setFileInput(pdf());
request.setAttachments(List.of(empty));
assertThrows(IllegalArgumentException.class, () -> controller.addAttachments(request));
verifyNoInteractions(pdfAttachmentService);
}
}
}
@@ -0,0 +1,210 @@
package stirling.software.SPDF.controller.api.misc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import java.awt.Color;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import stirling.software.SPDF.model.api.misc.AutoSplitPdfRequest;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager;
/**
* Additional branch coverage for {@link AutoSplitPdfController}: the actual splitting that happens
* when a recognised QR divider is embedded mid-document, plus duplex-mode skipping of the divider
* back page. QR images are generated with zxing; no rendering of external resources is required.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("AutoSplitPdfController additional branch tests")
class AutoSplitPdfControllerMoreTest {
private static final String VALID_QR = "https://stirlingpdf.com";
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
private ApplicationProperties applicationProperties;
private AutoSplitPdfController controller;
@TempDir java.nio.file.Path tempDir;
@BeforeEach
void setUp() throws Exception {
applicationProperties = new ApplicationProperties();
applicationProperties.getSystem().setMaxDPI(150);
controller =
new AutoSplitPdfController(
pdfDocumentFactory, tempFileManager, applicationProperties);
when(tempFileManager.createTempFile(".zip"))
.thenAnswer(inv -> Files.createTempFile(tempDir, "split", ".zip").toFile());
when(pdfDocumentFactory.load(any(InputStream.class)))
.thenAnswer(inv -> Loader.loadPDF(inv.<InputStream>getArgument(0).readAllBytes()));
}
private static BufferedImage qrImage(String text, int size) throws Exception {
QRCodeWriter writer = new QRCodeWriter();
java.util.Map<EncodeHintType, Object> hints = new java.util.EnumMap<>(EncodeHintType.class);
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M);
hints.put(EncodeHintType.MARGIN, 4);
BitMatrix matrix = writer.encode(text, BarcodeFormat.QR_CODE, size, size, hints);
int w = matrix.getWidth();
int h = matrix.getHeight();
BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
for (int x = 0; x < w; x++) {
for (int y = 0; y < h; y++) {
image.setRGB(x, y, matrix.get(x, y) ? Color.BLACK.getRGB() : Color.WHITE.getRGB());
}
}
return image;
}
private static void addPlainPage(PDDocument doc) {
doc.addPage(new PDPage(new PDRectangle(300, 300)));
}
private static void addQrDividerPage(PDDocument doc, BufferedImage qr) throws Exception {
PDPage page = new PDPage(new PDRectangle(300, 300));
doc.addPage(page);
PDImageXObject xobj = LosslessFactory.createFromImage(doc, qr);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.drawImage(xobj, 25, 25, 250, 250);
}
}
private byte[] docToBytes(PDDocument doc) throws Exception {
try (doc) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private static AutoSplitPdfRequest request(byte[] bytes, Boolean duplex) {
AutoSplitPdfRequest req = new AutoSplitPdfRequest();
req.setFileInput(new MockMultipartFile("fileInput", "doc.pdf", "application/pdf", bytes));
req.setDuplexMode(duplex);
return req;
}
private static List<String> zipEntries(Resource res) throws Exception {
List<String> names = new ArrayList<>();
try (InputStream in = res.getInputStream();
ZipInputStream zis = new ZipInputStream(in)) {
ZipEntry e;
while ((e = zis.getNextEntry()) != null) {
names.add(e.getName());
zis.closeEntry();
}
}
return names;
}
@Nested
@DisplayName("Splitting on a QR divider")
class QrDividerSplit {
@Test
@DisplayName("a mid-document QR divider produces two output PDFs")
void dividerProducesTwoDocs() throws Exception {
BufferedImage qr = qrImage(VALID_QR, 250);
PDDocument doc = new PDDocument();
addPlainPage(doc); // content section 1
addQrDividerPage(doc, qr); // divider -> starts section 2
addPlainPage(doc); // content section 2
byte[] bytes = docToBytes(doc);
ResponseEntity<Resource> response = controller.autoSplitPdf(request(bytes, false));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<String> names = zipEntries(response.getBody());
// First doc = page 1; second doc = divider page + page 3.
assertThat(names).hasSize(2);
assertThat(names).containsExactly("doc_1.pdf", "doc_2.pdf");
}
@Test
@DisplayName("duplex mode drops the page following the divider")
void duplexDropsBackPage() throws Exception {
BufferedImage qr = qrImage(VALID_QR, 250);
PDDocument doc = new PDDocument();
addPlainPage(doc); // section 1
addQrDividerPage(doc, qr); // divider
addPlainPage(doc); // back of divider -> skipped in duplex
addPlainPage(doc); // section 2 content
byte[] bytes = docToBytes(doc);
ResponseEntity<Resource> response = controller.autoSplitPdf(request(bytes, true));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<String> names = zipEntries(response.getBody());
assertThat(names).hasSize(2);
}
@Test
@DisplayName("output PDFs from a divider split are individually loadable")
void outputsAreLoadable() throws Exception {
BufferedImage qr = qrImage(VALID_QR, 250);
PDDocument doc = new PDDocument();
addPlainPage(doc);
addQrDividerPage(doc, qr);
addPlainPage(doc);
byte[] bytes = docToBytes(doc);
ResponseEntity<Resource> response = controller.autoSplitPdf(request(bytes, false));
int total = 0;
try (InputStream in = response.getBody().getInputStream();
ZipInputStream zis = new ZipInputStream(in)) {
ZipEntry e;
while ((e = zis.getNextEntry()) != null) {
byte[] entry = zis.readAllBytes();
try (PDDocument loaded = Loader.loadPDF(entry)) {
total += loaded.getNumberOfPages();
}
zis.closeEntry();
}
}
// 3 source pages: the QR divider page itself is consumed as a boundary, leaving
// page 1 in the first doc and page 3 in the second (2 pages total).
assertThat(total).isEqualTo(2);
}
}
}
@@ -0,0 +1,327 @@
package stirling.software.SPDF.controller.api.misc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.awt.Color;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.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.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.misc.RemoveBlankPagesRequest;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ApplicationContextProvider;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
/**
* Endpoint-level coverage for {@link BlankPageController#removeBlankPages} using real PDFs so the
* text/image blank detection and zip-assembly logic actually run.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("BlankPageController removeBlankPages")
class BlankPageControllerMoreTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
private BlankPageController controller;
@BeforeEach
void setUp() throws Exception {
controller = new BlankPageController(pdfDocumentFactory, tempFileManager);
when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("blank_test", inv.<String>getArgument(0))
.toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
// load(MultipartFile) hands back a freshly loaded document from the upload bytes.
lenient()
.when(
pdfDocumentFactory.load(
any(org.springframework.web.multipart.MultipartFile.class)))
.thenAnswer(
inv -> {
org.springframework.web.multipart.MultipartFile mf = inv.getArgument(0);
return Loader.loadPDF(mf.getBytes());
});
// createNewDocument() returns a real, empty document the controller fills + saves.
lenient().when(pdfDocumentFactory.createNewDocument()).thenAnswer(inv -> new PDDocument());
}
private static byte[] textPage(String text) throws IOException {
try (PDDocument doc = new PDDocument();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 14);
cs.newLineAtOffset(72, 700);
cs.showText(text);
cs.endText();
}
doc.save(baos);
return baos.toByteArray();
}
}
private static byte[] emptyPages(int count) throws IOException {
try (PDDocument doc = new PDDocument();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
for (int i = 0; i < count; i++) {
PDPage page = new PDPage(PDRectangle.A4);
// Empty Resources so image detection has a dict to scan instead of NPE-ing.
page.setResources(new PDResources());
doc.addPage(page);
}
doc.save(baos);
return baos.toByteArray();
}
}
/** A document with one text page and one truly blank page. */
private static byte[] mixedTextAndBlank() throws IOException {
try (PDDocument doc = new PDDocument();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
PDPage textPage = new PDPage(PDRectangle.A4);
doc.addPage(textPage);
try (PDPageContentStream cs = new PDPageContentStream(doc, textPage)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 14);
cs.newLineAtOffset(72, 700);
cs.showText("Has content");
cs.endText();
}
PDPage blankPage = new PDPage(PDRectangle.A4);
// Empty Resources so image detection has a dict to scan instead of NPE-ing.
blankPage.setResources(new PDResources());
doc.addPage(blankPage);
doc.save(baos);
return baos.toByteArray();
}
}
/** A page that carries a black image so the image-based blank detection branch executes. */
private static byte[] imagePage(Color color) throws IOException {
try (PDDocument doc = new PDDocument();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
java.awt.image.BufferedImage img =
new java.awt.image.BufferedImage(
120, 120, java.awt.image.BufferedImage.TYPE_INT_RGB);
java.awt.Graphics2D g = img.createGraphics();
g.setColor(color);
g.fillRect(0, 0, 120, 120);
g.dispose();
PDImageXObject xobj = LosslessFactory.createFromImage(doc, img);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.drawImage(xobj, 100, 100, 200, 200);
}
doc.save(baos);
return baos.toByteArray();
}
}
private static RemoveBlankPagesRequest request(byte[] pdf, int threshold, float whitePercent) {
RemoveBlankPagesRequest req = new RemoveBlankPagesRequest();
req.setFileInput(new MockMultipartFile("fileInput", "in.pdf", "application/pdf", pdf));
req.setThreshold(threshold);
req.setWhitePercent(whitePercent);
return req;
}
private static List<String> zipNames(Resource resource) throws Exception {
List<String> names = new ArrayList<>();
try (ZipInputStream zis =
new ZipInputStream(new ByteArrayInputStream(resource.getContentAsByteArray()))) {
ZipEntry e;
while ((e = zis.getNextEntry()) != null) {
names.add(e.getName());
zis.closeEntry();
}
}
return names;
}
@Nested
@DisplayName("happy paths")
class HappyPaths {
@Test
@DisplayName("all-text document keeps every page in the non-blank PDF only")
void allTextPages() throws Exception {
ResponseEntity<Resource> response =
controller.removeBlankPages(request(textPage("Hello"), 10, 99.9f));
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
List<String> names = zipNames(response.getBody());
// Only non-blank pages exist, so only the non-blank entry is written.
assertEquals(1, names.size());
assertTrue(names.get(0).endsWith("_nonBlankPages.pdf"));
}
@Test
@DisplayName("document with only empty pages produces the all-blank entry")
void onlyBlankPages() throws Exception {
ResponseEntity<Resource> response =
controller.removeBlankPages(request(emptyPages(2), 10, 99.9f));
assertEquals(HttpStatus.OK, response.getStatusCode());
List<String> names = zipNames(response.getBody());
assertEquals(1, names.size());
assertTrue(names.get(0).endsWith("_allBlankPages.pdf"));
}
@Test
@DisplayName("mixed text and blank pages yields both non-blank and blank entries")
void mixedPages() throws Exception {
ResponseEntity<Resource> response =
controller.removeBlankPages(request(mixedTextAndBlank(), 10, 99.9f));
assertEquals(HttpStatus.OK, response.getStatusCode());
List<String> names = zipNames(response.getBody());
assertEquals(2, names.size());
assertTrue(names.stream().anyMatch(n -> n.endsWith("_nonBlankPages.pdf")));
assertTrue(names.stream().anyMatch(n -> n.endsWith("_blankPages.pdf")));
}
}
@Nested
@DisplayName("image-based blank detection")
class ImageDetection {
@Test
@DisplayName("page with a black image is treated as non-blank")
void blackImageIsNonBlank() throws Exception {
// Black image -> not enough white -> non-blank branch.
ResponseEntity<Resource> response =
controller.removeBlankPages(request(imagePage(Color.BLACK), 10, 99.9f));
assertEquals(HttpStatus.OK, response.getStatusCode());
List<String> names = zipNames(response.getBody());
assertTrue(names.get(0).endsWith("_nonBlankPages.pdf"));
}
@Test
@DisplayName("page with a white image counts as blank")
void whiteImageIsBlank() throws Exception {
// White image with a high white-percent threshold -> blank branch.
ResponseEntity<Resource> response =
controller.removeBlankPages(request(imagePage(Color.WHITE), 10, 50.0f));
assertEquals(HttpStatus.OK, response.getStatusCode());
List<String> names = zipNames(response.getBody());
assertEquals(1, names.size());
assertTrue(names.get(0).endsWith("_allBlankPages.pdf"));
}
@Test
@DisplayName("uses configured maxDPI when application properties bean is present")
void usesConfiguredMaxDpi() throws Exception {
ApplicationProperties props = new ApplicationProperties();
props.getSystem().setMaxDPI(40);
try (var mocked = org.mockito.Mockito.mockStatic(ApplicationContextProvider.class)) {
mocked.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class))
.thenReturn(props);
ResponseEntity<Resource> response =
controller.removeBlankPages(request(imagePage(Color.BLACK), 10, 99.9f));
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
}
@Nested
@DisplayName("error handling")
class Errors {
@Test
@DisplayName("loader IOException is caught and returned as a 500 response")
void corruptPdfReturnsServerError() throws Exception {
RemoveBlankPagesRequest req = request("garbage".getBytes(), 10, 99.9f);
when(pdfDocumentFactory.load(
any(org.springframework.web.multipart.MultipartFile.class)))
.thenThrow(new IOException("bad pdf"));
// The controller swallows IOException from the loader and maps it to HTTP 500.
ResponseEntity<Resource> response = controller.removeBlankPages(req);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
}
}
@Test
@DisplayName("createZipEntry writes a loadable PDF with the supplied pages")
void createZipEntryWritesPages() throws Exception {
try (PDDocument src = new PDDocument()) {
src.addPage(new PDPage(PDRectangle.A4));
src.addPage(new PDPage(PDRectangle.A4));
List<PDPage> pages = new ArrayList<>();
src.getPages().forEach(pages::add);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(baos)) {
controller.createZipEntry(zos, pages, "entry.pdf");
}
try (ZipInputStream zis =
new ZipInputStream(new ByteArrayInputStream(baos.toByteArray()))) {
ZipEntry entry = zis.getNextEntry();
assertEquals("entry.pdf", entry.getName());
try (PDDocument loaded = Loader.loadPDF(zis.readAllBytes())) {
assertEquals(2, loaded.getNumberOfPages());
}
}
}
}
}
@@ -0,0 +1,758 @@
package stirling.software.SPDF.controller.api.misc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
/**
* Additional unit tests for {@link CompressController} covering the Ghostscript / qpdf
* orchestration paths that the base test deliberately skips. Real external binaries are never
* launched: the static {@link ProcessExecutor} instance map is patched via reflection with mocks
* that fake a successful run and write a small valid PDF to the expected output file.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CompressControllerMoreTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private TempFileManager tempFileManager;
@Mock private ProcessExecutor ghostscriptExecutor;
@Mock private ProcessExecutor qpdfExecutor;
@InjectMocks private CompressController controller;
/** Real temp files created during a test; cleaned up after each test. */
private final List<File> createdFiles = new ArrayList<>();
/** Previous occupants of the static instances map, restored in tearDown. */
private ProcessExecutor previousGhostscript;
private ProcessExecutor previousQpdf;
private boolean hadGhostscript;
private boolean hadQpdf;
@BeforeEach
void setUp() throws Exception {
// Both external tool groups enabled so the orchestration paths run.
lenient().when(endpointConfiguration.isGroupEnabled(anyString())).thenReturn(true);
// Every managed temp file is backed by a real on-disk file wrapped in a mock TempFile.
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile(
"compress-more-test",
inv.<String>getArgument(0))
.toFile();
createdFiles.add(f);
return newRealBackedTempFile(f);
});
// The final reload and any image-compression reload return real PDFBox documents.
lenient()
.when(pdfDocumentFactory.load(any(File.class)))
.thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0)));
lenient()
.when(pdfDocumentFactory.load(any(Path.class)))
.thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile()));
installExecutorMocks();
}
@AfterEach
void tearDown() throws Exception {
restoreExecutorMocks();
for (File f : createdFiles) {
try {
Files.deleteIfExists(f.toPath());
} catch (Exception ignored) {
// best-effort cleanup
}
}
createdFiles.clear();
}
// ----- static ProcessExecutor instance-map patching ----------------------------------------
@SuppressWarnings("unchecked")
private Map<ProcessExecutor.Processes, ProcessExecutor> executorInstances() throws Exception {
Field field = ProcessExecutor.class.getDeclaredField("instances");
field.setAccessible(true);
return (Map<ProcessExecutor.Processes, ProcessExecutor>) field.get(null);
}
private void installExecutorMocks() throws Exception {
Map<ProcessExecutor.Processes, ProcessExecutor> instances = executorInstances();
hadGhostscript = instances.containsKey(ProcessExecutor.Processes.GHOSTSCRIPT);
previousGhostscript = instances.get(ProcessExecutor.Processes.GHOSTSCRIPT);
hadQpdf = instances.containsKey(ProcessExecutor.Processes.QPDF);
previousQpdf = instances.get(ProcessExecutor.Processes.QPDF);
instances.put(ProcessExecutor.Processes.GHOSTSCRIPT, ghostscriptExecutor);
instances.put(ProcessExecutor.Processes.QPDF, qpdfExecutor);
}
private void restoreExecutorMocks() throws Exception {
Map<ProcessExecutor.Processes, ProcessExecutor> instances = executorInstances();
if (hadGhostscript) {
instances.put(ProcessExecutor.Processes.GHOSTSCRIPT, previousGhostscript);
} else {
instances.remove(ProcessExecutor.Processes.GHOSTSCRIPT);
}
if (hadQpdf) {
instances.put(ProcessExecutor.Processes.QPDF, previousQpdf);
} else {
instances.remove(ProcessExecutor.Processes.QPDF);
}
}
private TempFile newRealBackedTempFile(File f) {
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
lenient().when(tf.getAbsolutePath()).thenReturn(f.getAbsolutePath());
lenient().when(tf.exists()).thenReturn(f.exists());
return tf;
}
// ----- executor stubbing helpers ------------------------------------------------------------
/**
* Build a result mock with the given return code; assigned to a local before any thenReturn.
*/
private ProcessExecutorResult resultWithRc(int rc) {
ProcessExecutorResult result = mock(ProcessExecutorResult.class);
lenient().when(result.getRc()).thenReturn(rc);
lenient().when(result.getMessages()).thenReturn("");
return result;
}
// Locate the -sOutputFile= path in a gs command.
private static Path ghostscriptOutputPath(List<String> command) {
for (String arg : command) {
if (arg.startsWith("-sOutputFile=")) {
return Path.of(arg.substring("-sOutputFile=".length()));
}
}
return null;
}
// The qpdf output path is the last argument of the command.
private static Path qpdfOutputPath(List<String> command) {
return Path.of(command.get(command.size() - 1));
}
/** Stub gs to write a valid PDF to its output file and report success. */
private void stubGhostscriptSuccess(byte[] pdfToWrite) throws Exception {
ProcessExecutorResult okResult = resultWithRc(0);
lenient()
.when(ghostscriptExecutor.runCommandWithOutputHandling(anyList()))
.thenAnswer(
inv -> {
List<String> command = inv.getArgument(0);
Path out = ghostscriptOutputPath(command);
if (out != null) {
Files.write(out, pdfToWrite);
}
return okResult;
});
}
/** Stub gs to report a non-zero, non-critical return code (output stays untouched). */
private void stubGhostscriptNonZero() throws Exception {
ProcessExecutorResult badResult = resultWithRc(1);
lenient()
.when(ghostscriptExecutor.runCommandWithOutputHandling(anyList()))
.thenReturn(badResult);
}
/** Stub qpdf to write a valid PDF to its output file and report success. */
private void stubQpdfSuccess(byte[] pdfToWrite) throws Exception {
ProcessExecutorResult okResult = resultWithRc(0);
lenient()
.when(qpdfExecutor.runCommandWithOutputHandling(anyList(), any()))
.thenAnswer(
inv -> {
List<String> command = inv.getArgument(0);
Path out = qpdfOutputPath(command);
if (out != null) {
Files.write(out, pdfToWrite);
}
return okResult;
});
}
// ----- tiny in-memory PDF builders ---------------------------------------------------------
private byte[] textOnlyPdfBytes() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.LETTER);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(
new org.apache.pdfbox.pdmodel.font.PDType1Font(
org.apache.pdfbox.pdmodel.font.Standard14Fonts.FontName.HELVETICA),
12);
cs.newLineAtOffset(50, 700);
cs.showText("Hello compress more");
cs.endText();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
/** PDF with one large (>400px) image so the image-compression branch actually resizes it. */
private byte[] largeImagePdfBytes() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.LETTER);
doc.addPage(page);
// >400px so the resize branch fires; filled via Graphics2D (instant vs per-pixel).
java.awt.image.BufferedImage img =
new java.awt.image.BufferedImage(
500, 500, java.awt.image.BufferedImage.TYPE_INT_RGB);
java.awt.Graphics2D g = img.createGraphics();
g.setColor(java.awt.Color.LIGHT_GRAY);
g.fillRect(0, 0, 500, 500);
g.setColor(java.awt.Color.DARK_GRAY);
g.fillRect(60, 60, 380, 380);
g.dispose();
PDImageXObject image = LosslessFactory.createFromImage(doc, img);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.drawImage(image, 50, 50, 400, 400);
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private MockMultipartFile multipart(byte[] bytes) {
return new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, bytes);
}
private static byte[] drain(ResponseEntity<Resource> response) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (java.io.InputStream in = response.getBody().getInputStream()) {
in.transferTo(baos);
}
return baos.toByteArray();
}
// ===========================================================================================
// Ghostscript orchestration
// ===========================================================================================
@Nested
@DisplayName("Ghostscript orchestration")
class Ghostscript {
@Test
@DisplayName("level 6 runs Ghostscript successfully and returns OK")
void level6_ghostscriptSuccess_returnsOk() throws Exception {
byte[] gsOut = textOnlyPdfBytes();
stubGhostscriptSuccess(gsOut);
stubQpdfSuccess(textOnlyPdfBytes());
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(largeImagePdfBytes()));
request.setOptimizeLevel(6);
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drain(response)).isNotEmpty();
}
@Test
@DisplayName("each optimize level 6-9 with Ghostscript enabled returns OK")
void levels6to9_ghostscriptSuccess_returnsOk() throws Exception {
// Stub once: re-stubbing a mock inside the loop re-invokes the previous answer
// with empty matcher args, tripping qpdfOutputPath's last-element lookup.
stubGhostscriptSuccess(textOnlyPdfBytes());
stubQpdfSuccess(textOnlyPdfBytes());
for (int level = 6; level <= 9; level++) {
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(largeImagePdfBytes()));
request.setOptimizeLevel(level);
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
@Test
@DisplayName("levels 1-5 skip Ghostscript (never invoked) but still return OK")
void lowLevels_ghostscriptNotInvoked_returnsOk() throws Exception {
stubGhostscriptSuccess(textOnlyPdfBytes());
stubQpdfSuccess(textOnlyPdfBytes());
for (int level = 1; level <= 5; level++) {
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(largeImagePdfBytes()));
request.setOptimizeLevel(level);
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
// gs only runs for levels >= 6.
org.mockito.Mockito.verify(ghostscriptExecutor, org.mockito.Mockito.never())
.runCommandWithOutputHandling(anyList());
}
@Test
@DisplayName("grayscale flag at level 6 still drives Ghostscript and returns OK")
void grayscale_level6_returnsOk() throws Exception {
stubGhostscriptSuccess(textOnlyPdfBytes());
stubQpdfSuccess(textOnlyPdfBytes());
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(largeImagePdfBytes()));
request.setOptimizeLevel(7);
request.setGrayscale(true);
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("non-zero Ghostscript exit is propagated as GhostscriptException")
void ghostscriptNonZeroExit_propagates() throws Exception {
// A non-zero gs return code is wrapped and rethrown (see optimizePdf's
// catch (GhostscriptException) -> throw e), not swallowed.
stubGhostscriptNonZero();
stubQpdfSuccess(textOnlyPdfBytes());
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(largeImagePdfBytes()));
request.setOptimizeLevel(6);
assertThatThrownBy(() -> controller.optimizePdf(request))
.isInstanceOf(ExceptionUtils.GhostscriptException.class);
}
@Test
@DisplayName("critical Ghostscript error is propagated as GhostscriptException")
void ghostscriptCriticalError_propagates() throws Exception {
// Output containing a recognized critical marker triggers
// detectGhostscriptCriticalError.
ProcessExecutorResult criticalResult = mock(ProcessExecutorResult.class);
lenient().when(criticalResult.getRc()).thenReturn(0);
lenient()
.when(criticalResult.getMessages())
.thenReturn("Page 1\nERROR: Could not draw this page");
lenient()
.when(ghostscriptExecutor.runCommandWithOutputHandling(anyList()))
.thenReturn(criticalResult);
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(largeImagePdfBytes()));
request.setOptimizeLevel(8);
assertThatThrownBy(() -> controller.optimizePdf(request))
.isInstanceOf(ExceptionUtils.GhostscriptException.class);
}
@Test
@DisplayName("Ghostscript IOException is wrapped and propagated as GhostscriptException")
void ghostscriptIOException_propagates() throws Exception {
// An IOException from the gs executor is wrapped via
// createGhostscriptCompressionException and rethrown, not swallowed.
lenient()
.when(ghostscriptExecutor.runCommandWithOutputHandling(anyList()))
.thenThrow(new IOException("gs boom"));
stubQpdfSuccess(textOnlyPdfBytes());
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(largeImagePdfBytes()));
request.setOptimizeLevel(6);
assertThatThrownBy(() -> controller.optimizePdf(request))
.isInstanceOf(ExceptionUtils.GhostscriptException.class);
}
}
// ===========================================================================================
// QPDF orchestration
// ===========================================================================================
@Nested
@DisplayName("QPDF orchestration")
class Qpdf {
@Test
@DisplayName("low level with only qpdf enabled recompresses and returns OK")
void lowLevel_qpdfOnly_returnsOk() throws Exception {
// Disable Ghostscript so qpdf is the only external tool that runs.
lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
stubQpdfSuccess(textOnlyPdfBytes());
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(largeImagePdfBytes()));
request.setOptimizeLevel(2);
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drain(response)).isNotEmpty();
}
@Test
@DisplayName("linearize option drives qpdf --linearize and returns OK")
void linearize_qpdf_returnsOk() throws Exception {
lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
// Capture the qpdf command so we can assert --linearize is present.
List<List<String>> captured = new ArrayList<>();
ProcessExecutorResult okResult = resultWithRc(0);
byte[] pdfToWrite = textOnlyPdfBytes();
lenient()
.when(qpdfExecutor.runCommandWithOutputHandling(anyList(), any()))
.thenAnswer(
inv -> {
List<String> command = inv.getArgument(0);
captured.add(command);
Path out = qpdfOutputPath(command);
Files.write(out, pdfToWrite);
return okResult;
});
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(largeImagePdfBytes()));
request.setOptimizeLevel(3);
request.setLinearize(true);
request.setNormalize(true);
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(captured).isNotEmpty();
assertThat(captured.get(0)).contains("--linearize");
assertThat(captured.get(0)).contains("--normalize-content=y");
}
@Test
@DisplayName("higher level enables qpdf --optimize-images and jpeg quality")
void highLevel_qpdfOptimizeImages_returnsOk() throws Exception {
lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
List<List<String>> captured = new ArrayList<>();
ProcessExecutorResult okResult = resultWithRc(0);
byte[] pdfToWrite = textOnlyPdfBytes();
lenient()
.when(qpdfExecutor.runCommandWithOutputHandling(anyList(), any()))
.thenAnswer(
inv -> {
List<String> command = inv.getArgument(0);
captured.add(command);
Files.write(qpdfOutputPath(command), pdfToWrite);
return okResult;
});
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(largeImagePdfBytes()));
request.setOptimizeLevel(5);
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(captured.get(0)).contains("--optimize-images");
}
@Test
@DisplayName("qpdf IOException is swallowed; processing still returns OK")
void qpdfIOException_swallowed_returnsOk() throws Exception {
lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
lenient()
.when(qpdfExecutor.runCommandWithOutputHandling(anyList(), any()))
.thenThrow(new IOException("qpdf boom"));
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(largeImagePdfBytes()));
request.setOptimizeLevel(2);
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("Ghostscript + qpdf both enabled at high level returns OK")
void ghostscriptAndQpdf_highLevel_returnsOk() throws Exception {
stubGhostscriptSuccess(textOnlyPdfBytes());
stubQpdfSuccess(textOnlyPdfBytes());
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(largeImagePdfBytes()));
request.setOptimizeLevel(9);
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
// ===========================================================================================
// Target-size (auto) mode iterative loop
// ===========================================================================================
@Nested
@DisplayName("target expected-size (auto) mode")
class AutoMode {
@Test
@DisplayName("auto mode reaches target on first pass when gs shrinks enough")
void autoMode_targetMetFirstPass_returnsOk() throws Exception {
// gs writes a tiny PDF so the very first size check meets a generous target.
byte[] tiny = textOnlyPdfBytes();
stubGhostscriptSuccess(tiny);
stubQpdfSuccess(tiny);
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(largeImagePdfBytes()));
request.setOptimizeLevel(null);
request.setExpectedOutputSize("10MB"); // easily met
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drain(response)).isNotEmpty();
}
@Test
@DisplayName("auto mode escalates the level when target is not met, then terminates")
void autoMode_escalatesLevel_returnsOk() throws Exception {
// gs always writes the same moderate PDF whose size stays just above the target,
// forcing the loop to escalate optimizeLevel from a low start until it caps at 9.
byte[] moderate = largeImagePdfBytes();
// Target ~25% of input => start level 6 (gs eligible); gs success skips image
// compression so size stays constant and the loop escalates 6 -> 9 (>=2 gs calls).
long target = moderate.length / 4;
final AtomicInteger gsCalls = new AtomicInteger();
ProcessExecutorResult okResult = resultWithRc(0);
lenient()
.when(ghostscriptExecutor.runCommandWithOutputHandling(anyList()))
.thenAnswer(
inv -> {
gsCalls.incrementAndGet();
List<String> command = inv.getArgument(0);
Path out = ghostscriptOutputPath(command);
if (out != null) {
Files.write(out, moderate);
}
return okResult;
});
stubQpdfSuccess(moderate);
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(moderate));
request.setOptimizeLevel(null);
request.setExpectedOutputSize(target + "B"); // never met => escalates through levels
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// Escalation reaches gs-eligible levels (>=6) more than once before bailing at max.
assertThat(gsCalls.get()).isGreaterThan(1);
}
@Test
@DisplayName("auto mode with qpdf only (low starting level) escalates and returns OK")
void autoMode_qpdfOnly_returnsOk() throws Exception {
lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false);
byte[] moderate = largeImagePdfBytes();
stubQpdfSuccess(moderate);
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(moderate));
request.setOptimizeLevel(null);
request.setExpectedOutputSize("1KB");
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
// ===========================================================================================
// Image compression branches reached via the controller
// ===========================================================================================
@Nested
@DisplayName("image compression branches")
class ImageCompression {
@Test
@DisplayName("level 4 with a large image and no gs (level<6) compresses the image")
void level4_largeImage_compresses_returnsOk() throws Exception {
// Disable both external tools so the Java image path is exercised end-to-end.
lenient().when(endpointConfiguration.isGroupEnabled(anyString())).thenReturn(false);
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(largeImagePdfBytes()));
request.setOptimizeLevel(4);
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drain(response)).isNotEmpty();
}
@Test
@DisplayName("grayscale at low level compresses image via Java path when tools disabled")
void grayscale_lowLevel_javaPath_returnsOk() throws Exception {
lenient().when(endpointConfiguration.isGroupEnabled(anyString())).thenReturn(false);
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(largeImagePdfBytes()));
request.setOptimizeLevel(2);
request.setGrayscale(true);
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("compressImagesInPDF scales a large image and grayscale converts it")
void compressImagesInPDF_largeImage_grayscale() throws Exception {
Path src = tempDir.resolve("large.pdf");
Files.write(src, largeImagePdfBytes());
TempFile result = controller.compressImagesInPDF(src, 0.5, 0.6f, true);
assertThat(result).isNotNull();
byte[] out = Files.readAllBytes(result.getPath());
try (PDDocument doc = Loader.loadPDF(out)) {
assertThat(doc.getNumberOfPages()).isEqualTo(1);
}
}
@Test
@DisplayName("compressImagesInPDF at high quality on a large image still produces a PDF")
void compressImagesInPDF_largeImage_highQuality() throws Exception {
Path src = tempDir.resolve("large2.pdf");
Files.write(src, largeImagePdfBytes());
TempFile result = controller.compressImagesInPDF(src, 0.9, 0.95f, false);
assertThat(result).isNotNull();
assertThat(Files.readAllBytes(result.getPath())).isNotEmpty();
}
}
// ===========================================================================================
// Result-size guard: optimized output never larger than original
// ===========================================================================================
@Nested
@DisplayName("output size guard")
class OutputSizeGuard {
@Test
@DisplayName("when gs output is larger than original, the original is returned")
void gsLargerThanOriginal_usesOriginal_returnsOk() throws Exception {
byte[] original = textOnlyPdfBytes();
// gs writes a deliberately bloated file so the >= inputFileSize guard trips.
byte[] bloated = largeImagePdfBytes();
stubGhostscriptSuccess(bloated);
stubQpdfSuccess(bloated);
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(original));
request.setOptimizeLevel(6);
ResponseEntity<Resource> response = controller.optimizePdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(drain(response)).isNotEmpty();
}
}
// ===========================================================================================
// Validation still holds with tools enabled
// ===========================================================================================
@Nested
@DisplayName("validation with tools enabled")
class Validation {
@Test
@DisplayName("null file still throws IllegalArgumentException even with tools enabled")
void nullFile_throws() {
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(null);
assertThatThrownBy(() -> controller.optimizePdf(request))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("no optimize options provided throws IllegalArgumentException")
void noOptions_throws() throws Exception {
OptimizePdfRequest request = new OptimizePdfRequest();
request.setFileInput(multipart(textOnlyPdfBytes()));
request.setOptimizeLevel(null);
request.setExpectedOutputSize(null);
assertThatThrownBy(() -> controller.optimizePdf(request))
.isInstanceOf(IllegalArgumentException.class);
}
}
}
@@ -0,0 +1,221 @@
package stirling.software.SPDF.controller.api.misc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.context.ApplicationContext;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.ExternalAppDepConfig;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.LicenseServiceInterface;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.common.service.UserServiceInterface;
/**
* Exercises getAppConfig and the dynamic license/EE helpers, which the original
* ConfigControllerTest does not cover. Uses a real ApplicationProperties so the full method body
* runs.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("ConfigController extra coverage")
class ConfigControllerMoreTest {
@Mock private ApplicationContext applicationContext;
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private ServerCertificateServiceInterface serverCertificateService;
@Mock private UserServiceInterface userService;
@Mock private LicenseServiceInterface licenseService;
@Mock private ExternalAppDepConfig externalAppDepConfig;
@Mock private AppConfig appConfig;
@Mock private Environment environment;
private ApplicationProperties applicationProperties;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
when(appConfig.getBackendUrl()).thenReturn("http://localhost:8080");
when(appConfig.getContextPath()).thenReturn("/");
when(appConfig.getServerPort()).thenReturn("8080");
when(applicationContext.getBean(AppConfig.class)).thenReturn(appConfig);
lenient().when(applicationContext.getEnvironment()).thenReturn(environment);
lenient().when(externalAppDepConfig.isDependenciesChecked()).thenReturn(true);
}
private ConfigController newController() {
return new ConfigController(
applicationProperties,
applicationContext,
endpointConfiguration,
serverCertificateService,
userService,
licenseService,
externalAppDepConfig);
}
@SuppressWarnings("unchecked")
private Map<String, Object> bodyOf(ResponseEntity<Map<String, Object>> resp) {
return resp.getBody();
}
@Nested
@DisplayName("getAppConfig")
class GetAppConfig {
@Test
@DisplayName("returns wired config values with all services present")
void returnsConfigWithServices() {
when(licenseService.isRunningProOrHigher()).thenReturn(true);
when(licenseService.isRunningEE()).thenReturn(false);
when(licenseService.getLicenseTypeName()).thenReturn("ENTERPRISE");
when(userService.isCurrentUserAdmin()).thenReturn(true);
when(userService.isCurrentUserFirstLogin()).thenReturn(false);
when(serverCertificateService.isEnabled()).thenReturn(true);
applicationProperties.getSecurity().setEnableLogin(true);
HttpServletRequest request = mock(HttpServletRequest.class);
ResponseEntity<Map<String, Object>> resp = newController().getAppConfig(request);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = bodyOf(resp);
assertThat(body).containsEntry("dependenciesReady", true);
assertThat(body).containsEntry("baseUrl", "http://localhost:8080");
assertThat(body).containsEntry("serverPort", "8080");
assertThat(body).containsEntry("enableLogin", true);
assertThat(body).containsEntry("isAdmin", true);
assertThat(body).containsEntry("runningProOrHigher", true);
assertThat(body).containsEntry("license", "ENTERPRISE");
assertThat(body).containsEntry("serverCertificateEnabled", true);
assertThat(body).containsKey("timestampTsaPresets");
}
@Test
@DisplayName("login disabled when userService is null (proprietary not loaded)")
void loginDisabledWhenNoUserService() {
userService = null;
licenseService = null;
serverCertificateService = null;
applicationProperties.getSecurity().setEnableLogin(true);
HttpServletRequest request = mock(HttpServletRequest.class);
ResponseEntity<Map<String, Object>> resp = newController().getAppConfig(request);
Map<String, Object> body = bodyOf(resp);
assertThat(body).containsEntry("enableLogin", false);
assertThat(body).containsEntry("isAdmin", false);
assertThat(body).containsEntry("isNewUser", false);
assertThat(body).containsEntry("serverCertificateEnabled", false);
}
@Test
@DisplayName("falls back to context beans when license service is null")
void licenseFallsBackToContextBeans() {
licenseService = null;
when(applicationContext.containsBean("runningProOrHigher")).thenReturn(true);
when(applicationContext.getBean("runningProOrHigher", Boolean.class)).thenReturn(true);
when(applicationContext.containsBean("runningEE")).thenReturn(true);
when(applicationContext.getBean("runningEE", Boolean.class)).thenReturn(true);
when(applicationContext.containsBean("license")).thenReturn(true);
when(applicationContext.getBean("license", String.class)).thenReturn("SERVER");
when(applicationContext.containsBean("SSOAutoLogin")).thenReturn(true);
when(applicationContext.getBean("SSOAutoLogin", Boolean.class)).thenReturn(true);
HttpServletRequest request = mock(HttpServletRequest.class);
ResponseEntity<Map<String, Object>> resp = newController().getAppConfig(request);
Map<String, Object> body = bodyOf(resp);
assertThat(body).containsEntry("runningProOrHigher", true);
assertThat(body).containsEntry("runningEE", true);
assertThat(body).containsEntry("license", "SERVER");
assertThat(body).containsEntry("SSOAutoLogin", true);
}
@Test
@DisplayName("includes Google Drive backend settings when enabled")
void googleDriveEnabled() {
ApplicationProperties.Premium.ProFeatures.GoogleDrive gd =
applicationProperties.getPremium().getProFeatures().getGoogleDrive();
gd.setEnabled(true);
gd.setClientId("cid");
gd.setApiKey("key");
gd.setAppId("aid");
HttpServletRequest request = mock(HttpServletRequest.class);
ResponseEntity<Map<String, Object>> resp = newController().getAppConfig(request);
Map<String, Object> body = bodyOf(resp);
assertThat(body).containsEntry("googleDriveEnabled", true);
assertThat(body).containsEntry("googleDriveClientId", "cid");
assertThat(body).containsEntry("googleDriveApiKey", "key");
}
@Test
@DisplayName("includes version/machine info beans when available")
void versionAndMachineBeans() {
when(applicationContext.containsBean("appVersion")).thenReturn(true);
when(applicationContext.getBean("appVersion", String.class)).thenReturn("9.9.9");
when(applicationContext.containsBean("machineType")).thenReturn(true);
when(applicationContext.getBean("machineType", String.class)).thenReturn("Docker");
when(applicationContext.containsBean("activeSecurity")).thenReturn(true);
when(applicationContext.getBean("activeSecurity", Boolean.class)).thenReturn(true);
HttpServletRequest request = mock(HttpServletRequest.class);
ResponseEntity<Map<String, Object>> resp = newController().getAppConfig(request);
Map<String, Object> body = bodyOf(resp);
assertThat(body).containsEntry("appVersion", "9.9.9");
assertThat(body).containsEntry("machineType", "Docker");
assertThat(body).containsEntry("activeSecurity", true);
}
@Test
@DisplayName("isCurrentUserAdmin exception leaves isAdmin false")
void adminCheckExceptionSwallowed() {
when(userService.isCurrentUserAdmin()).thenThrow(new RuntimeException("boom"));
when(userService.isCurrentUserFirstLogin()).thenThrow(new RuntimeException("boom"));
applicationProperties.getSecurity().setEnableLogin(true);
HttpServletRequest request = mock(HttpServletRequest.class);
ResponseEntity<Map<String, Object>> resp = newController().getAppConfig(request);
Map<String, Object> body = bodyOf(resp);
assertThat(body).containsEntry("isAdmin", false);
assertThat(body).containsEntry("isNewUser", false);
}
@Test
@DisplayName("returns basic config with error key when AppConfig bean lookup fails")
void returnsErrorConfigOnException() {
when(applicationContext.getBean(AppConfig.class))
.thenThrow(new RuntimeException("no bean"));
HttpServletRequest request = mock(HttpServletRequest.class);
ResponseEntity<Map<String, Object>> resp = newController().getAppConfig(request);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = bodyOf(resp);
assertThat(body).containsEntry("error", "Unable to retrieve full configuration");
}
}
}

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