Compare commits

...
Author SHA1 Message Date
Frooodle 23a3ef3080 merge main 2026-06-24 10:49:51 +01:00
Ludy 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 Brunton 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
EthanHealy01 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
EthanHealy01 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 Stirling 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 Brunton 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 Brunton f2b65f4a77 Make pre-commit scripts more OS-agnostic (#6724)
# Description of Changes
Fix #6723
2026-06-23 08:42:18 +00:00
James Brunton 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 Brunton 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 Brunton 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 Browne 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
Ludy 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
dependabot[bot]andAnthony Stirling 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 Stirling a3fe15bfd0 Add metrics for numerical count of total PDFs (#6737) 2026-06-19 18:57:03 +01:00
EthanHealy01andJames Brunton 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 Stirling 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 Brunton 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 Stirling 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 Brunton 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 Brunton 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]andAnthony 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]andAnthony 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]andAnthony 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 Brunton 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
Ludy 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 Stirling e6d476297d Clean up update dialog UI and fix desktop external links (#6727) 2026-06-18 22:20:02 +01:00
stirlingbot[bot] 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
Anthony StirlingandEthanHealy01 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
ConnorYohandEthanHealy01 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
Anthony StirlingandEthanHealy01 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 Brunton 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
EthanHealy01 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 Browne 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 Stirling 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 Browne 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 Brunton 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
albanobattistella b1fef4c647 Update Italian translations (#6713) 2026-06-18 08:35:31 +00:00
EthanHealy01 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 Stirling d9e6041a75 set z-index on config dropdowns so they render above the modal (#6674) 2026-06-18 09:08:50 +01:00
Anthony Stirling 8f81fdc762 Use glibc base for ultra-lite and bundle per-arch JPDFium natives (#6706) 2026-06-18 08:32:55 +01:00
James Brunton 13af10a6d1 Redesign policy running (#6609)
# Description of Changes
Redesign policy running so the server is in charge of policy IDs and
running, to make it impossible to have the frontend miss the results.
This solves a minor bug that we currently have in policies, where if you
load a file and then refresh while the policy is running, you'll never
receive the outputted file.
2026-06-17 16:18:50 +00:00
EthanHealy01 3750111ffc fix agent overlay chat position when workbench size changes (#6682)
<img width="2056" height="1047" alt="Screenshot 2026-06-16 at 12 42
05 AM"
src="https://github.com/user-attachments/assets/74a38b93-f31f-4263-bb62-24c2334a22e8"
/>
<img width="1443" height="1051" alt="Screenshot 2026-06-16 at 12 42
33 AM"
src="https://github.com/user-attachments/assets/adb3ba47-f3e6-44a7-bbc3-2097e15843b6"
/>
2026-06-17 15:54:09 +00:00
ConnorYoh 20c88feabb refactor(saas): remove the legacy credits engine (FE + Java) (#6687)
Complete legacy-credits teardown ("Group 3"). The per-user/per-team
credit model is fully superseded by PAYG (`wallet_ledger`) — confirmed
no PAYG code references it. Authorized to also remove the `TeamCredit`
pool + its monthly reset.

## Frontend (saas)
- Deleted `saas/hooks/useCredits.ts`, `apiKeys/hooks/useCredits.ts`,
`types/credits.ts`, `apiKeys/UsageSection.tsx`.
- `UseSession.tsx`: removed credit members (`creditBalance`,
`creditSummary`, `hasSufficientCredits`, `updateCredits`,
`refreshCredits`, `fetchCredits`) + the credit types + global
credit-update callback. **Kept** `isPro`/`refreshProStatus` and the
Supabase auth subscription listener.
- `services/apiClient.ts`: removed the dead `x-credits-remaining`
handler + low-credit plumbing (token-refresh / PAYG / 401 logic
untouched).
- Credit refs removed from `ApiKeys.tsx`, `AppConfigModal.tsx`,
`auth/teamSession.ts`.

## Java (:saas)
**Deleted (15):** `UserCredit`(+repo),
`TeamCredit`(+repo)+`TeamCreditService`, `CreditService`,
`CreditHeaderUtils`, `CreditResetScheduler`, `CreditController`,
`CreditInterceptorConfig`, `UnifiedCreditInterceptor`,
`CreditSuccessAdvice`, `CreditErrorAdvice`, `CreditConsumptionResult` (+
the CreditController test).

**Edited — stripped legacy credit side-effects, preserved
auth/role/AI/PAYG logic:**
- `AiCreate`/`AiProxyController`: dropped the
`X-Credits-Remaining`/`X-Credit-Source` response header (its only
consumer, the desktop credit system, was already removed).
- `SaasTeamService`: dropped UserCredit/TeamCredit init on team-create +
seat-update.
- `SupabaseAuthenticationFilter` / `SupabaseSecurityConfig`: dropped
`getOrCreateUserCredits` on signup + the credit field/CORS header.
- `UserRoleService`: dropped `resetCycleAllocationForRoleChange`;
`ROLE_PRO_USER` grant/revoke preserved.
- proprietary `UserRepository`: dropped
`findUsersWithApiKeyButNoCredits()`.
- Tests updated to drop credit mocks/refs.

## Kept / scope
- `isPro` / `is_pro` RPC / `ROLE_PRO_USER` (that's the separate Group-4
/ EE effort) and **all PAYG** are untouched.
- **No DB tables dropped.** `user_credits`/`team_credits` stay until a
later **gated** migration — which this PR unblocks (the JPA entities
that pinned them are gone).

## Verify
`:saas:compileJava` + `:saas:compileTestJava` pass; FE `tsc --noEmit`
(saas) + eslint clean; 0 stray artifacts; no residual source refs to the
deleted classes.

## Follow-up (not in this PR)
`ErrorTrackingService` (+
`UserErrorTracker`/`ProcessingErrorType`/`CreditsProperties`) is now a
dead island — its only callers were the deleted interceptors. Safe to
delete, but it cascades beyond the credit scope, so it's a separate
tidy-up.

Targets `feat/desktop-cloud-saas-reuse`.
2026-06-17 14:11:06 +00:00
ConnorYoh 4f26fdeb5c feat(desktop): show the AI assistant in SaaS mode via the cloud kill switch (#6666)
## What & why

Chained on top of #6649 (the `cloud/` refactor). The AI assistant was
effectively dead on desktop:

1. **Hidden** — `ChatFAB` gates on `aiEngineEnabled`, which desktop
reads from the **local** bundled backend's `/api/v1/config/app-config`.
The local backend has no AI engine, so the flag is always `false` and
the FAB never renders.
2. **Mis-routed** — even if shown, AI calls used `getApiBaseUrl()`,
which is empty/local on desktop, so the orchestrate stream and AI
result-file download missed the engine (which only runs in the cloud).

This PR wires AI properly **without hardcoding it on**, so the cloud
keeps the kill switch: flip `aiEngineEnabled` server-side and the
desktop FAB disappears on the next load — no desktop release required.
(Deliberately *not* assume-on, so a future "turn AI off" doesn't strand
shipped versions.)

## Changes

**General SaaS app-config service** (reusable for any cloud flag, not
just AI):
- `desktop/services/saasAppConfigService.ts` — SaaS-mode-only fetch +
5-min cache of the **public** `/api/v1/config/app-config` from the
**SaaS** backend over native HTTP (`@tauri-apps/plugin-http`, no CORS).
Returns `null` outside SaaS mode.
- `desktop/hooks/useSaasAppConfig.ts` — hook over it; reloads on
connection-mode change.

**AI gating + routing seams:**
- `useAiEngineEnabled()` — core reads `useAppConfig()` (web), desktop
reads `useSaasAppConfig()`. `ChatFAB` consumes it.
- `getAiBaseUrl()` — core uses the normal API base (web), desktop points
AI calls at the SaaS backend. `ChatContext` uses it for the orchestrate
stream + result-file download.
- `operationRouter` — route `/api/v1/ai/*` to the SaaS backend
(cloud-only prefix).

**Docs:** AGENTS.md gains a short "cloud feature flags on desktop" note
so the pattern is maintained.

## Verification
- `tsc --noEmit` green for saas / desktop / cloud flavors
- `eslint --max-warnings=0` clean (cloud-layer guardrail respected — the
platform-coupled bits live in `desktop/`)
- New `saasAppConfigService.test.ts` (3 tests) + existing
`operationRouter` / `tauriHttpClient` / `httpErrorHandler` suites green
- 0 stray compiled artifacts

## Not headlessly verifiable — needs a live Tauri smoke
The orchestrate **SSE stream** uses the webview's global `fetch` (native
HTTP can't stream the body the same way), so it's subject to browser
CORS to the SaaS backend. The `SupabaseSecurityConfig` tauri-origin
allowance (from #6649) covers it, but please confirm on a real build:
open the FAB in SaaS mode, run an agent task, watch the stream + a
result-file download succeed.
2026-06-17 14:10:35 +00:00
Anthony Stirling df9dbc5179 MCP token rejection reason and stop logging the raw tokens (#6700)
- Surface the real reason an MCP token is rejected: the 401's
WWW-Authenticate header now includes error_description
(audience/issuer/expiry), and a present-but-rejected token logs the
concrete OAuth2 reason. Tokenless 401s (the normal discovery handshake)
stay at debug.
- Add McpConfigValidator that sanity-checks MCP config at startup and
logs actionable warnings (missing issuer-uri/resource-id, unrecognized
auth mode, sub + require-existing-account, open access, scopes,
allow/block overlap) so misconfig shows up in the logs before a client
ever connects.
- Align the audience-rejection message to mention both resource-id and
accepted-audiences.
- Harden audit writes: hash JWT-shaped or over-long principals
(token:<sha256-prefix>) so the insert fits the column and never stores a
raw bearer token, and stop logging the raw principal on persist failure.
---

## 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-17 11:06:05 +00:00
Anthony Stirling de9242c4f7 Add JUnit tests for saas module coverage (#6699)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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-17 11:04:53 +00:00
Anthony Stirling 460c037bbb Prefer JBoss mirror over shibboleth repo for opensaml (#6701)
# Description of Changes

Jboss not shibboleth first

---

## 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-17 10:55:59 +00:00
ConnorYohandJames Brunton cd7264a76a refactor(fe): share the SaaS PAYG experience with desktop via a cloud/ layer (#6649)
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-06-17 11:12:05 +01:00
James Brunton ef0deef4f2 Skip flaky Playwright test (#6698)
# Description of Changes
One of the Playwright tests is flaky, despite several attempts to fix it
before it made it into main. This disables the test for now so a
followup PR can try to fix it again.
2026-06-17 09:12:09 +00:00
65fcc036fe Fix inverted link toolbar in rotated PDFs (#6518) (#6684)
Closes #6518 

# Cause of the bug 
This is a fix to the #6518 issue. The bug happened because the link
toolbar was rendered inside the PDF page layer. That layer can be
affected by the viewer/page rotation transform, so the toolbar was laid
out using local page coordinates and then visually transformed together
with the page.

As a result, the placement logic could calculate a position that was
correct in the page’s local coordinate space, such as above or below the
link, but the parent transform would rotate or shift that result after
layout. On rotated pages, this could make the toolbar appear on the
wrong side, inverted, or misaligned relative to the link.

More specifically, in the PDF that exposed the bug, the page content
appears to have been authored upside down and then corrected with a
180-degree page/viewer rotation so it looks normal to the user.

Because the toolbar was rendered inside the same transformed page layer,
it inherited that 180-degree rotation as well. The PDF content looked
upright because the rotation was part of how the page was displayed, but
the toolbar is viewer UI and should not be rotated with the page. As a
result, the tooltip appeared upside down even though the PDF itself
looked correct.


# Description of Changes

Fixes the inverted link tooltip/toolbar positioning in rotated PDF
viewer pages.

The link toolbar is now rendered through a body portal and positioned
from the link element’s real viewport bounds, so page rotation
transforms no longer flip or misalign it.

The update also keeps the toolbar within the viewport during scroll,
resize, zoom, and rotation changes, preserves the hover delay between
the link and toolbar, centralizes the z-index in a shared constant, and
improves label sizing to avoid clipped text.

Note: The link hover styling was also changed from an underline to a
subtle rectangular highlight based on the PDF link annotation bounds.

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] 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)

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

UI behaviour before the changes :

<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-12-10"
src="https://github.com/user-attachments/assets/321edbb3-42a2-4bc3-96ad-3ccc70a355b8"
/>

<img width="762" height="496" alt="Captura de tela de 2026-06-16
00-11-46"
src="https://github.com/user-attachments/assets/be4c1af4-5488-4a54-9b6f-675e3bea73b8"
/>

<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-12-57"
src="https://github.com/user-attachments/assets/60f44cd5-c772-44a8-97c8-bde135764e53"
/>


UI behaviour after the changes :
 
<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-22-02"
src="https://github.com/user-attachments/assets/dda77bda-0780-4807-a70d-3bbc60683e5a"
/>

<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-23-07"
src="https://github.com/user-attachments/assets/5745c37e-438a-4bbe-ba1e-c6f2098421de"
/>

<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-23-24"
src="https://github.com/user-attachments/assets/85932541-4a6f-48e4-879f-41f34a6d79e6"
/>


### Testing (if applicable)

- [X] I have run `task check` to verify linters, typechecks, and tests
pass
- [X] 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: James Brunton <jbrunton96@gmail.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-17 08:15:31 +00:00
Reece Browne f127d4f575 fix(policies): poll runs to completion with progress, soft-retry when queue is full (#6690)
## What & why

Production reports of policy enforcement "hanging" traced to
large/many-page documents: the watermark step's flatten-to-image
(`convertPDFToImage`) on a 500+ page PDF takes minutes, exceeding both
the client poll cap and the backend per-step timeout. This makes the
slow case graceful instead of looking broken, and makes load-shedding
non-fatal.

### Poll runs to completion (no false "hang")
The client poll loop used a flat ~150s cap that was **shorter than the
backend's 300s per-step timeout**, so it abandoned long-but-healthy runs
mid-flight. The budget is now sized to the backend's real worst case —
`stepCount × per-step timeout + grace`, learned from the first status
report — so the client always polls long enough to surface the run's
**actual** terminal state (success or the backend's real error) rather
than a misleading client-side timeout.

### Per-step progress
The activity feed now shows `Enforcing… · step n/m` (from
`currentStep`/`stepCount`), so a slow step shows movement instead of a
dead spinner.

### Soft-retry on queue rejection
Under load the shared `JobQueue` rejects runs ("queue full"), which
previously surfaced as a hard failure needing a manual Retry. The
backend now tags that rejection with a stable `POLICY_QUEUE_FULL`
errorCode; the client treats it as transient backpressure and
**auto-retries the file in place** with exponential backoff (≈4s→64s, ~2
min), shown as a soft "Busy — retrying…" row, falling back to the manual
Retry only once the retry budget is spent.

## Testing
- **Frontend unit tests** (30 pass across the policies suite), including
a new `usePolicyAutoRun.retry.test.tsx` that drives the real controller
orchestration (poll → `POLICY_QUEUE_FULL` → relabel → backoff → in-place
re-dispatch), plus poll-budget, step-progress, and activity-feed relabel
cases.
- **Backend** `PolicyEngineTest` case asserting a queue-rejected run
carries the `POLICY_QUEUE_FULL` code.
- Typecheck clean on all three flavors (proprietary/saas/core); prettier
+ spotless clean.
- Poll-budget + progress + real-error surfacing were also verified live
end-to-end against a 599-page run (survived past the old cap, showed
step progress, reported the backend's real 300s-timeout failure,
recovered after a simulated network drop).

## Not included (follow-ups)
- The underlying flatten-to-image cost itself (bounded-memory/streaming
flatten, revisiting `convertPDFToImage` default and the 300s timeout) —
the real perf fix, deliberately out of scope here.
2026-06-16 17:32:12 +00:00
Anthony Stirling f33f4f8f75 Add JUnit tests for common and core module coverage (#6675)
# Description of Changes

JUNITS!
They JUnits were 100% AI generated however no code was touched

---

## 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-16 16:23:34 +00:00
7e67bfc459 Fix SaaS issues (#6694)
# Description of Changes

Fixes several SaaS issues,  was integration branch for saas release

---

## 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: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: Reece <reece@stirlingpdf.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
2026-06-16 17:16:07 +01:00
Anthony Stirling 686fb1fb50 Revert "SaaS fixes" (#6693)
Reverts Stirling-Tools/Stirling-PDF#6578 due to mistaken squash merge
not normal merge
2026-06-16 17:13:10 +01:00
Anthony Stirling 5389e39cfc Revert "SaaS fixes (#6578)"
This reverts commit ddf78d11ae.
2026-06-16 16:48:30 +01:00
ddf78d11ae SaaS fixes (#6578)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: Reece <reece@stirlingpdf.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
2026-06-16 16:41:25 +01:00
James Brunton 10b4551449 Fix failing Playwright test in SaaS (#6688)
# Description of Changes
Fix Playwright failing test in SaaS (I think this is my third attempt
now so who knows if this will actually fix it for real this time, but
hopefully it does)
2026-06-16 15:56:30 +01:00
Reece Browne 96accea984 Portal: full mock-driven surfaces, demonolithed components, backend-ready mocks (#6686) 2026-06-16 12:20:35 +01:00
James Brunton 9a883be697 Cleanup of SaaS code (#6669)
# Description of Changes
De-AI comments and fix ridiculously indented code
2026-06-16 11:49:13 +01:00
dependabot[bot]andAnthony Stirling 6716398ccb build(deps): bump go-task/setup-task from 2.0.0 to 2.1.0 (#6429)
Bumps [go-task/setup-task](https://github.com/go-task/setup-task) from
2.0.0 to 2.1.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/go-task/setup-task/releases">go-task/setup-task's
releases</a>.</em></p>
<blockquote>
<h2>v2.1.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Replaced <code>typed-rest-client</code> with
<code>@actions/http-client</code> for GitHub API calls
to eliminate the Node 24 <code>DEP0169</code> deprecation warning about
<code>url.parse()</code> (<a
href="https://redirect.github.com/go-task/setup-task/issues/5">#5</a> by
<a href="https://github.com/vmaerten"><code>@​vmaerten</code></a>).</li>
<li>Modernized the TypeScript tooling stack (vitest, oxlint,
<code>@actions/core@2</code>,
<code>@actions/io@2</code>, updated <code>@types/node</code>,
<code>@vercel/ncc</code>, <code>prettier</code>, etc.) (<a
href="https://redirect.github.com/go-task/setup-task/issues/5">#5</a> by
<a href="https://github.com/vmaerten"><code>@​vmaerten</code></a>).</li>
<li>Migrated the project to ESM (sources + bundle). Aligns with the new
<code>@actions/*</code> ESM-only majors and produces a ~47% smaller
<code>dist/index.js</code> (<a
href="https://redirect.github.com/go-task/setup-task/issues/5">#5</a> by
<a href="https://github.com/vmaerten"><code>@​vmaerten</code></a>).</li>
<li>Upgraded <code>@actions/core</code> 2 → 3,
<code>@actions/http-client</code> 2 → 4,
<code>@actions/io</code> 2 → 3, <code>@actions/tool-cache</code> 2 → 4,
<code>typescript</code> 5 → 6, and
<code>markdownlint-cli</code> 0.47 → 0.48 (<a
href="https://redirect.github.com/go-task/setup-task/issues/5">#5</a> by
<a href="https://github.com/vmaerten"><code>@​vmaerten</code></a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/go-task/setup-task/blob/main/CHANGELOG.md">go-task/setup-task's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2>Unreleased</h2>
<h2>v2.1.0 - 2026-05-17</h2>
<ul>
<li>Replaced <code>typed-rest-client</code> with
<code>@actions/http-client</code> for GitHub API calls
to eliminate the Node 24 <code>DEP0169</code> deprecation warning about
<code>url.parse()</code>.</li>
<li>Modernized the TypeScript tooling stack (vitest, oxlint,
<code>@actions/core@2</code>,
<code>@actions/io@2</code>, updated <code>@types/node</code>,
<code>@vercel/ncc</code>, <code>prettier</code>, etc.).</li>
<li>Migrated the project to ESM (sources + bundle). Aligns with the new
<code>@actions/*</code> ESM-only majors and produces a ~47% smaller
<code>dist/index.js</code>.</li>
<li>Upgraded <code>@actions/core</code> 2 → 3,
<code>@actions/http-client</code> 2 → 4,
<code>@actions/io</code> 2 → 3, <code>@actions/tool-cache</code> 2 → 4,
<code>typescript</code> 5 → 6, and
<code>markdownlint-cli</code> 0.47 → 0.48.</li>
</ul>
<h2>v2.0.0 - 2026-03-18</h2>
<ul>
<li><strong>BREAKING</strong>: Upgraded to Node 24. Requires a GitHub
Actions runner with
Node.js 24 support
(<a
href="https://redirect.github.com/go-task/setup-task/pull/10">#10</a> by
<a href="https://github.com/vmaerten"><code>@​vmaerten</code></a>).</li>
</ul>
<h2>v1.1.0 - 2026-03-17</h2>
<ul>
<li>Added configurable HTTP retry for API requests
(<a href="https://redirect.github.com/go-task/setup-task/pull/7">#7</a>
by <a
href="https://github.com/vmaerten"><code>@​vmaerten</code></a>).</li>
</ul>
<h2>v1.0.0 - 2025-09-12</h2>
<ul>
<li>Forked <a
href="https://github.com/arduino/setup-task">arduino/setup-task</a> (by
<a href="https://github.com/pd93"><code>@​pd93</code></a>).</li>
<li>Default <code>repo-token</code> to <code>{{github.token}}</code>
(<a
href="https://redirect.github.com/arduino/setup-task/pull/642">arduino/setup-task#642</a>
by
<a href="https://github.com/shrink"><code>@​shrink</code></a>).</li>
<li>Fixed a bug where the action would fail is Task pushed a tag without
a release
(<a
href="https://redirect.github.com/arduino/setup-task/pull/490">arduino/setup-task#490</a>,
<a
href="https://redirect.github.com/arduino/setup-task/pull/1193">arduino/setup-task#1193</a>
by
<a href="https://github.com/trim21"><code>@​trim21</code></a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/go-task/setup-task/commit/01a4adf9db2d14c1de7a560f09170b6e0df736aa"><code>01a4adf</code></a>
chore: release v2.1.0</li>
<li><a
href="https://github.com/go-task/setup-task/commit/56fc0886350e15a75ed1e6f4b7a83d3b831b3054"><code>56fc088</code></a>
fix(taskfile): make mktemp utilities portable across BSD and GNU</li>
<li><a
href="https://github.com/go-task/setup-task/commit/4de50203767624993e228e2135c1d13cc156b095"><code>4de5020</code></a>
chore(release): add release task automation (<a
href="https://redirect.github.com/go-task/setup-task/issues/14">#14</a>)</li>
<li><a
href="https://github.com/go-task/setup-task/commit/f95f6c5aebc71143d70361ab79d87c447fd4c48f"><code>f95f6c5</code></a>
chore(deps): update all dependencies (<a
href="https://redirect.github.com/go-task/setup-task/issues/12">#12</a>)</li>
<li><a
href="https://github.com/go-task/setup-task/commit/dc4f00abd355059e622d428a1a905dfcd1169477"><code>dc4f00a</code></a>
chore(deps): update all dependencies (<a
href="https://redirect.github.com/go-task/setup-task/issues/2">#2</a>)</li>
<li><a
href="https://github.com/go-task/setup-task/commit/035a5f11fa6bbb298cc436d4173402c6ebfbc411"><code>035a5f1</code></a>
chore: modernize stack (<a
href="https://redirect.github.com/go-task/setup-task/issues/5">#5</a>)</li>
<li><a
href="https://github.com/go-task/setup-task/commit/099972a06751959896ae32ae844ed17001f59da5"><code>099972a</code></a>
docs: mark v2.0.0 release in changelog</li>
<li>See full diff in <a
href="https://github.com/go-task/setup-task/compare/3be4020d41929789a01026e0e427a4321ce0ad44...01a4adf9db2d14c1de7a560f09170b6e0df736aa">compare
view</a></li>
</ul>
</details>
<br />

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-15 22:04:16 +00:00
dependabot[bot]andAnthony Stirling 5b20257dea build(deps): bump actions/stale from 10.2.0 to 10.3.0 (#6487)
Bumps [actions/stale](https://github.com/actions/stale) from 10.2.0 to
10.3.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/stale/releases">actions/stale's
releases</a>.</em></p>
<blockquote>
<h2>v10.3.0</h2>
<h2>What's Changed</h2>
<h3>Bug Fix</h3>
<ul>
<li>Enhancement: ignore stale labeling events by <a
href="https://github.com/shamoon"><code>@​shamoon</code></a> in <a
href="https://redirect.github.com/actions/stale/pull/1311">actions/stale#1311</a></li>
</ul>
<h3>Dependency Updates</h3>
<ul>
<li>Upgrade dependencies (<code>@​actions/core</code>,
<code>@​octokit/plugin-retry</code>, <a
href="https://github.com/typescript-eslint"><code>@​typescript-eslint</code></a>)
by <a href="https://github.com/Copilot"><code>@​Copilot</code></a> in <a
href="https://redirect.github.com/actions/stale/pull/1335">actions/stale#1335</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/shamoon"><code>@​shamoon</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/stale/pull/1311">actions/stale#1311</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/stale/compare/v10...v10.3.0">https://github.com/actions/stale/compare/v10...v10.3.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/stale/commit/eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899"><code>eb5cf3a</code></a>
chore: upgrade dependencies and bump version to 10.3.0 (<a
href="https://redirect.github.com/actions/stale/issues/1335">#1335</a>)</li>
<li><a
href="https://github.com/actions/stale/commit/db5d06a4c82d5e94513c09c406638111df61f63e"><code>db5d06a</code></a>
Enhancement: ignore stale labeling events (<a
href="https://redirect.github.com/actions/stale/issues/1311">#1311</a>)</li>
<li>See full diff in <a
href="https://github.com/actions/stale/compare/b5d41d4e1d5dceea10e7104786b73624c18a190f...eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/stale&package-manager=github_actions&previous-version=10.2.0&new-version=10.3.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)


</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-15 22:04:09 +00:00
dependabot[bot]andAnthony Stirling 2f5fc7be4e build(deps): bump depot/build-push-action from 1.17.0 to 1.18.0 (#6488)
Bumps
[depot/build-push-action](https://github.com/depot/build-push-action)
from 1.17.0 to 1.18.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/depot/build-push-action/releases">depot/build-push-action's
releases</a>.</em></p>
<blockquote>
<h2>v1.18.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Upgrade action runtime to Node 24 (<a
href="https://redirect.github.com/depot/build-push-action/issues/48">#48</a>)
<a href="https://github.com/Akatama"><code>@​Akatama</code></a></li>
<li>Add Depot Registry save example (<a
href="https://redirect.github.com/depot/build-push-action/issues/47">#47</a>)
<a href="https://github.com/maschwenk"><code>@​maschwenk</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/depot/build-push-action/commit/98e78adca7817480b8185f474a400b451d74e287"><code>98e78ad</code></a>
Merge pull request <a
href="https://redirect.github.com/depot/build-push-action/issues/48">#48</a>
from depot/upgrade-node-24-runtime</li>
<li><a
href="https://github.com/depot/build-push-action/commit/e97ebff18729ac91be067461138674a006ab9bff"><code>e97ebff</code></a>
Remove Node 24 compatibility docs</li>
<li><a
href="https://github.com/depot/build-push-action/commit/2db929fa768ebb3ad332ae8fc530412bf0964782"><code>2db929f</code></a>
Upgrade action runtime to Node 24</li>
<li><a
href="https://github.com/depot/build-push-action/commit/f78af826a1c272c4b60c485e934974b515094928"><code>f78af82</code></a>
Merge pull request <a
href="https://redirect.github.com/depot/build-push-action/issues/47">#47</a>
from maschwenk/maschwenk/add-depot-registry-example</li>
<li><a
href="https://github.com/depot/build-push-action/commit/6855818d5954fa4361879bb0b0e5c32856fc6703"><code>6855818</code></a>
Update action.yml</li>
<li><a
href="https://github.com/depot/build-push-action/commit/b984f6a1944d5420eefb2b012d6eb856249bd225"><code>b984f6a</code></a>
Clarify save/save-tag/save-tags input descriptions</li>
<li><a
href="https://github.com/depot/build-push-action/commit/1a34abd3707433f4f7b6d594e49e56b4b9f4d6d0"><code>1a34abd</code></a>
Add Depot Registry save example</li>
<li>See full diff in <a
href="https://github.com/depot/build-push-action/compare/5f3b3c2e5a00f0093de47f657aeaefcedff27d18...98e78adca7817480b8185f474a400b451d74e287">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=depot/build-push-action&package-manager=github_actions&previous-version=1.17.0&new-version=1.18.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)


</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-15 22:04:03 +00:00
Anthony Stirling d18caf6116 Fix PDF text selection locked out on touch devices (#6656) 2026-06-15 23:04:43 +01:00
dependabot[bot]andAnthony Stirling 3bafbb1919 build(deps): bump docker/metadata-action from 6.0.0 to 6.1.0 (#6490)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-15 23:03:55 +01:00
James Brunton 42c1cce56d Fix Java formatting 2026-06-15 15:05:26 +01:00
fb6a118be9 Update SaaS to latest main (#6667)
# Description of Changes
> [!warning]
> **Do not** squash this on merge. It should be merged via a merge
commit

Fixes conflicts in `pgvector_store.py`. 

Also since codespell is failing, add comments to ignore the errors in
`sync_en_us_spelling.py`

---------

Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-15 14:53:22 +01:00
James Brunton c55beacead Shut codespell up 2026-06-15 14:20:25 +01:00
James Brunton 04d68c650a Merge remote-tracking branch 'origin/main' into saas-update
# Conflicts:
#	engine/src/stirling/documents/pgvector_store.py
2026-06-15 14:10:21 +01:00
Anthony Stirling 9d7467cf90 Scope signing user picker to team for multi-tenant SaaS (#6583) 2026-06-15 13:44:34 +01:00
James Brunton 2a905c01c3 SaaS tidying (#6665)
# Description of Changes
* Remove complex port selection logic from `engine.yml`. It's
inconsistent with the frontend & backend task files, and caused issues
with Docker, which have been worked around but would be simpler to just
get rid of the problem altogether
* Fix Ruff formatting of Python script
* Remove payg tests which are failing and have drifted too far from the
implementation to save directly
2026-06-15 13:21:33 +01:00
Anthony Stirling d6a5777c69 Fix pgvector (#6591) 2026-06-15 13:09:40 +01:00
James Brunton c1a637d764 Fix CI errors in SaaS (#6662)
# Description of Changes
Fix CI errors in #6578 to make SaaS branch ready for merge into main
2026-06-15 11:26:29 +01:00
LudyandJames Brunton 085ad6c784 build(taskfile): use platform-specific Gradle wrapper command (#6355)
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-06-14 15:50:31 +01:00
Ludy cb13102117 chore(frontend): make Vite allowed hosts configurable (#6354) 2026-06-14 15:49:45 +01:00
Ludy 1fa1293b39 chore(build): upgrade Gradle wrapper and Docker build images to 9.5.1 (#6501) 2026-06-14 15:48:44 +01:00
Ludy 1ce765ab1e refactor: replace legacy Paths usage with Path.of (#6441) 2026-06-14 15:48:28 +01:00
Ludy dad2425c27 docs: Document pluralization suffix usage for translations (#6650) 2026-06-14 15:47:57 +01:00
Anthony Stirling eefa8eff61 Route mobile scanner API and vendor loads through the app base path (#6648) 2026-06-12 16:00:11 +01:00
Reece Browne 63ecbe3b6d Policies: centre the collapsed-rail policy button between its dividers (#6646) 2026-06-12 13:39:03 +01:00
Anthony Stirling f1ed850a73 Fix SaaS mobile scanner being auth-gated under /app base path (#6642) 2026-06-12 13:13:49 +01:00
Anthony Stirling b11c272e87 Feature/v2/guest action gating (#6643) 2026-06-12 13:13:38 +01:00
Reece Browne f5e697347b Policies: drop the Pro-license gate from the policy API (#6645)
`PolicyController` was annotated `@PremiumEndpoint` (requires a
Pro-or-higher server license). Policies don't need a server-license
gate:

- On SaaS the server runs in Pro mode, so the check is always satisfied
anyway — it gates nothing in practice.
- Access is already governed by team scoping (#6632) plus the per-user /
guest gates.

So the annotation is dead weight and misleading. This removes it (and
its import) — a 2-line change.

## Verification
- `:proprietary:compileJava` succeeds; spotless clean. No other premium
gate on policy classes.
2026-06-12 13:13:01 +01:00
Reece Browne 4e880c7510 Policies: summon the guest sign-up banner when a guest clicks a policy (#6644)
Guests (anonymous users on a login-enabled deployment) could open a
policy's setup/detail. Policies are an account feature, so a guest
clicking a policy should be nudged to sign up rather than opening it.

## Behaviour
A guest clicking a policy row — or a collapsed-rail icon — now
**re-summons the existing guest sign-up banner** ("You're using Stirling
PDF as a guest!…") and does **not** open the policy.

- `GuestUserBanner` listens for a `stirling:show-guest-banner` window
event and re-shows (even if previously dismissed; the render guard still
hides it for non-anonymous users).
- The policy sidebar dispatches that event on a guest click (cross-layer
via `CustomEvent`, same pattern as `payg:signupRequired`; a no-op on
builds without the banner).
- `usePolicyGuestBlocked()` gates it: `config.enableLogin === true &&
user.is_anonymous === true`.
- **Login-disabled single-user** deployments have an anonymous local
operator with full access → not gated.

## Verification
- Typecheck clean (proprietary + saas); eslint clean; sidebar tests
pass.

## Note
No dedicated guest unit test — the suite mocks `useAppConfig` at module
scope, and making it per-test controllable needs `vi.hoisted` plumbing
that risked the existing tests. Easy follow-up.
2026-06-12 13:10:37 +01:00
James Brunton 511b92b321 De-AI the onboarding prose (#6641)
# Description of Changes
De-AI the onboarding prose.
2026-06-12 11:39:19 +01:00
ConnorYoh 87723d3ce2 fix(payg): fire the usage-limit modal when an AI agent run hits the limit (#6638)
## Problem

We're getting 402s when an AI **agent** (chat) run hits the free
allowance / spending cap, but the frontend handles them poorly and never
pops the usage-limit modal.

The agent runs its tool calls **server-side** (loopback HTTP via
`PolicyExecutor`), so the 402 never reaches the `apiClient` interceptor
that pops the modal for direct calls. It was caught by the generic
tool-failure handler and flattened into a `CANNOT_CONTINUE` reason
string (`"The /api/v1/… tool failed: 402…"`), streamed as a `result`
event, and rendered as a scary chat bubble. This is the same gap the
policy auto-run path bridges (#6626) — one layer up.

## Fix

**Backend** (`proprietary`)
- `AiWorkflowResponse` gains `errorCode` + `errorSubscribed`.
- `AiWorkflowService` detects a downstream 401/402 entitlement sentinel
in its three tool-exec catch sites (`onToolCall`, `runPlan`,
`onConvertMarkdown`) and surfaces the structured code (+ `subscribed`)
on the terminal response instead of the raw failure text.
- Factored the 401/402 body extraction `PolicyEngine` already had into a
shared `DownstreamEntitlementError` util so the two server-side paths
can't drift.

**Frontend**
- New `usageLimitBridge` (`PAYG_LIMIT_REACHED_EVENT` +
`dispatchPaygLimitReached`) generalises the previously policy-only
bridge. Proprietary can't import the saas modal API (layering), so
server-side limit hits broadcast a window event the saas
`UsageLimitModalHost` opens the modal from. Migrated the policy path
onto it.
- `ChatContext` fires the matching modal (free → subscribe, subscribed →
raise cap) on the limit result **and** on a direct 402, replacing the
raw reason with a brief friendly line
(`chat.responses.usage_limit_reached`).

No Python engine changes — the charge/402 happens on the Java tool
endpoint that Java itself calls.

## Test plan

- [x] `:proprietary:compileJava` + `spotlessCheck` clean
- [x] `AiWorkflowServiceTest` + `PolicyEngineTest` green
- [x] eslint, proprietary + saas typechecks clean
- [ ] Manual: drive an agent run over the limit → brief line in chat +
the right modal (free vs cap)

> Note: proprietary test compilation is currently blocked on the
pre-existing `InitialSecuritySetupTest` 6-arg ctor break (unrelated,
tracked separately); verified locally by temporarily patching it.
2026-06-12 11:38:07 +01:00
EthanHealy01 eb2527fc7f Properly sync US and GB translation files (#6635)
add en-US changes to SaaS, previously merged into main. So this is
effectively a main -> SaaS PR also. It seems to be all additive.

Also take the 230 ish missing translations from en-GB over to en-US
using a script, and also make and english spellings American when adding
them to the en-US file, and fix any existing American spellings in the
en-GB file.
2026-06-12 11:18:25 +01:00
James Brunton d363a1e957 Improve search logic (#6637)
# Description of Changes
Search has got significantly worse since #6581, where I added all the
missing tags for tools that should have been there for months. Turns out
that the fuzzy matching search logic has always been way too permissive
to match words with Levenshtein distances way too far away from the
target word, so long searches include way too much stuff. The new tags
just exposed that underlying logic issue. This PR makes the Levenshtein
logic much stricter, so it is still tolerant to minor typos in tool
names, but doesn't match completely inappropriate strings.
2026-06-12 10:34:11 +01:00
James Brunton ea102cdb93 Merge pull request #6636 from Stirling-Tools/SaaS-update
Update SaaS to latest main
2026-06-12 10:19:35 +01:00
James Brunton d995471a55 Merge remote-tracking branch 'origin/main' into SaaS-update
# Conflicts:
#	frontend/editor/src/proprietary/components/chat/ChatContext.tsx
#	frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx
2026-06-12 09:58:40 +01:00
Reece Browne e3e49c07ae Policies: let team leaders configure policies in the UI (#6634)
Frontend follow-up to #6632 (team-scoped policies, editing gated to team
leaders on the backend). Brings the UI's edit gate in line.

## Problem
The policy config UI gated editing to `config.isAdmin`. On SaaS, org
users are never the single global admin, so **no one could open the
policy editor** — the same lockout #6632 fixed on the backend.

## Fix
`usePolicies` now allows a **team leader** to configure, falling back to
a global admin self-hosted:

```ts
canConfigure =
  config != null && (!config.enableLogin || isTeamLeader || config.isAdmin === true);
```

- SaaS → `isTeamLeader` (from `useSaaSTeam()`) — team leaders can
configure; members get the read-only surface.
- Self-hosted → `config.isAdmin` (the core `useSaaSTeam` stub returns
`false`, so admins aren't locked out).
- Login disabled (single-user) → always allowed.
- The `config != null` guard keeps the gate closed until app-config
resolves, so edit controls never flash for users who can't use them.

The two locked-policy banners now read "Contact a team leader to change
this policy" (updated in the `t()` defaults and the `en-GB`
translations).

## Verification
- Typecheck clean (proprietary + saas); eslint clean.
- Tests pass: `usePolicies`, `PoliciesSidebar`.
2026-06-11 23:51:26 +01:00
EthanHealy01 962119e14f UI ux/add ai warning and change style (#6633)
<img width="394" height="426" alt="Screenshot 2026-06-11 at 11 39 50 PM"
src="https://github.com/user-attachments/assets/15805931-73fd-416b-841b-99a556468433"
/>
bottom text input is sticky even when shrunk down
2026-06-11 23:49:52 +01:00
Reece BrowneandClaude Opus 4.8 cc1235bbf2 i18n(policies): route policy UI strings through i18n (English only) (#6628)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 23:37:43 +01:00
Reece Browne e88d22d2fc Policies: scope to the owning team; editing restricted to team leaders (#6632) 2026-06-11 23:21:48 +01:00
Anthony Stirling ddf10f0aaf Stop advertising mcp.tools scopes in OAuth metadata when scope enforcement is disabled 2026-06-11 23:03:43 +01:00
EthanHealy01 b756b5befb add agent warning and update style (#6629) 2026-06-11 21:55:51 +01:00
ConnorYoh eddc54c6c0 fix(payg): land usage-limit modal CTAs on the Plan section (#6630) 2026-06-11 21:42:59 +01:00
ConnorYoh 22379fd5ab fe(payg): show the usage-limit modal when the limit is hit (direct + policy) (#6626) 2026-06-11 21:28:44 +01:00
Reece Browne 6f1c19c179 Policies: enforce input on uploads only; badge follows edited files (#6627) 2026-06-11 21:27:44 +01:00
Reece BrowneandClaude Opus 4.8 ef65e6b015 feat(policies): org-wide policies with admin-only editing (#6625)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 21:23:06 +01:00
Anthony Stirling 47e5977a31 Drop startup credit-reset catch-up (bulk per-user loop; lazy reset covers it) 2026-06-11 21:21:28 +01:00
Anthony Stirling 3a4b340313 Remove legacy CreditBackfillRunner (PAYG replaces it; rows created lazily) 2026-06-11 21:03:09 +01:00
EthanHealy01 41d2aa8174 UI ux/move footer links to settings (#6606)
<img width="2056" height="1044" alt="Screenshot 2026-06-11 at 2 15
34 PM"
src="https://github.com/user-attachments/assets/e58a9f8f-7172-4f30-ab28-0760b66249c9"
/>
<img width="2056" height="1045" alt="Screenshot 2026-06-11 at 2 15
43 PM"
src="https://github.com/user-attachments/assets/890b7a0b-740f-4c7f-9a48-c9a2c28e8ded"
/>
2026-06-11 20:43:33 +01:00
ConnorYoh ee9fdeed6b fix(payg): run the entitlement guard before the charge interceptor (#6622)
## Problem

When the `EntitlementGuard` refuses a request with **402** (team is over
its free allowance / spending cap, or has no subscription to bill), the
handler never runs — so it must not charge. But it did: the guard (order
**1100**) ran *after* the charge interceptor (**1000**), so
`openProcess` had already written the charge before the 402, and
`afterCompletion` then billed it as "customer paid for the attempt" — a
ledger debit, and a **Stripe meter for a subscribed-over-cap team**.

## Fix

**Run the guard first** (order **900**, before the charge interceptor at
1000). Spring runs interceptors in ascending order on the way in and
**skips a later interceptor's `preHandle` (and `afterCompletion`)
entirely once an earlier one returns `false`** — so a refused request
short-circuits with its 402 *before* the charge interceptor runs at all.
A blocked request never opens a process, materialises inputs, or writes
a charge.

This replaces the earlier attribute-flag + `afterCompletion`-refund
approach with a simpler reorder (per review): the no-charge-on-block
guarantee is now **structural**, and it also avoids the wasted
open-then-refund churn (no temp-file write, no debit/refund pair) for
refused requests.

### Why the reorder is safe
- `EntitlementGuard` reads no `PaygChargeInterceptor` state and has **no
`afterCompletion`** (only `preHandle`), so reverse-order teardown is a
non-issue.
- The legacy `UnifiedCreditInterceptor` (default order **0**, and only
registered under the `legacy-credits` profile) still runs first, so any
legacy rejection wins.
- For *admitted* requests both interceptors still run (guard then
charge) — behaviour is unchanged; only refused requests now
short-circuit before the charge.

## Tests

`PaygWebMvcConfigTest` locks the `ENTITLEMENT_GUARD_ORDER <
INTERCEPTOR_ORDER` invariant (if it's ever reversed, refused requests
would bill again — this fails first). Existing `EntitlementGuardTest`
already proves the guard returns 402 on a degraded/billable request.
`:saas:test` + spotless green.

## Related (separate, in progress)

The **fail-cleanly + fire-the-modal** half (suppress the error toast,
trigger the subscribe/raise-cap modal via the existing
`subscribed`/`category` signal — catching the 402 centrally so direct
API usage is handled, and propagating the entitlement reason through the
async policy-run status) lands **with Ethan's modal** so we don't remove
the toast before there's a popup to replace it.
2026-06-11 20:42:40 +01:00
Anthony Stirling b1a960a240 Skip self-host user-table bootstrap (team backfill, grandfathering) in SaaS 2026-06-11 20:39:48 +01:00
Anthony Stirling 946c032fb5 Change default language to en-US and add US language (#6621) 2026-06-11 20:36:23 +01:00
EthanHealy01 7e493226c4 add popups for free limit hit and spend cap hit (#6623) 2026-06-11 20:34:59 +01:00
Anthony Stirling d48017a5b5 Skip engine free-port probe in containers (fixed port) 2026-06-11 20:18:54 +01:00
ConnorYoh 37b4d24a95 fix(payg): gate + charge AI document tools and AI Create sessions (#6617)
## Problem

Two AI surfaces slipped through PAYG unbilled:

1. **AI document tools** — `/api/v1/ai/tools/**`
(`PdfCommentAgentController`, `MathAuditorAgentController`) live in the
**proprietary** module, which can't depend on `saas` and so can't carry
the saas-only `@RequiresFeature`. They also lacked
`@AutoJobPostMapping`, so the charge interceptor's scope gate
short-circuited them **before** category resolution: **not charged, and
not even entitlement-gated** — whether called directly or dispatched by
the orchestrator.
2. **AI Create** — `/api/v1/ai/create` is JSON/session-based with no
file input, so the multipart charge path never fired. The old
per-generation charge ran through the now-dead legacy credit system, so
it currently charges nothing.

## Fix

- **`AiToolRoutes`** (new, saas) — single source of truth for the
`/api/v1/ai/tools/**` prefix. The proprietary controllers stay
untouched; the saas hot-path recognises them by path:
- **`PaygChargeInterceptor`**: brings these routes into scope and bills
them **AI** on a direct call. An orchestrator-dispatched call still
resolves to **AUTOMATION** first (the `X-Stirling-Automation` header is
checked before the path rule), so AI-tool-inside-a-workflow keeps
billing as automation.
  - **`EntitlementGuard`**: gates them on **`AI_SUPPORT`**.
- This keeps the `proprietary → saas` layering intact (no backwards
dependency).
- **`JobChargeService.chargeStandalone(ctx, units)`** — charges a fixed
unit count for a non-file billable action, reusing the existing
free-grant split + shadow row + ledger debit + `close()`→meter path on a
standalone bookkeeping job (no lineage inputs, so nothing lineage-joins
it). **`JobService.open(ctx, docUnits)`** opens that bare job.
- **`AiCreateController.createSession`** — charges **one document per
session** at creation (best-effort; entitlement is already enforced
upstream by the class-level `@RequiresFeature(AI_SUPPORT)`). Follow-up
edits (`outline` / `reprompt` / `draft` / `template` / `stream`) carry
**no** charge — they have no charge hook, so "charge on create,
follow-ups free" falls out naturally.

Per the agreed scope: charge AI Create on create now; we can optimise
follow-up handling later. **AI workflow categorisation (AUTOMATION vs
AI) intentionally left as-is** (the orchestrator's automation header
dominates by design).

## Tests

- `PaygChargeInterceptorTest`: AI-tool route (no annotations) is in
scope + **AI** category; same route with the automation header →
**AUTOMATION**; a plain non-AI route still short-circuits.
- `EntitlementGuardTest`: AI-tool route is in scope + gated on
**AI_SUPPORT** (degraded team → 402; anonymous → 401 with `category:
AI`).
- `JobChargeServiceTest`: `chargeStandalone` charges + meters the paid
portion for a subscribed team, draws the free grant (no meter) for an
unsubscribed team, and rejects `BYPASSED`.

`:saas:test` + `:saas:spotlessCheck` green; coverage gates met.

## Follow-ups (not in this PR)

- Make AI Create follow-ups explicitly cheaper / chained if we want
(currently free by absence of a hook).
- Decide whether AI-tool-inside-a-workflow should bill as AI rather than
AUTOMATION.
2026-06-11 20:10:44 +01:00
ConnorYoh aaa2599e23 fix(saas): block accepting an invite when it would orphan a paid team (#6616)
## Problem

A **free team can invite a paid team's leader** to join. The leader
accepts, and:
- `acceptInvitation` moves them onto the inviting team and removes their
membership from the old team, but only deletes the old team if it's
**personal**.
- Their paid (non-personal) team is left **memberless but still
subscribed** — an orphaned Stripe subscription billing for a team nobody
is in.

## Root cause

Two gaps in `SaasTeamService.acceptInvitation`:

1. The pre-accept guard checks `hasPaidSubscription(acceptingUser)` →
`existsActivePaidSubscriptionForUser(supabaseId)`, keyed on
**`user_id`**. A team's plan is keyed on **`team_id`**
(`existsActiveSubscriptionForTeam`), so a paid team's *leader* isn't
caught and accepts freely.
2. The 'leave existing teams' loop deletes the membership directly and
only marks **personal** teams for deletion — it bypasses the last-leader
protection `leaveTeam` already enforces (`"Cannot leave as the last team
leader. Transfer leadership first."`), and never cleans up / cancels the
non-personal team.

There is no in-app subscription-cancel path (cancellation is
Stripe-portal/webhook driven), so nothing reconciles the orphan after
the fact — it has to be prevented.

## Fix

Add `assertCanLeaveCurrentTeamsToJoinAnother(user)`, called in
`acceptInvitation` before any membership changes. For each non-personal
team where the user is the **last leader**, the accept is rejected:
- team has an active subscription → *"Cancel the plan or transfer
leadership before joining another team."*
- otherwise → *"Transfer leadership before joining another team."*

This mirrors the protection `leaveTeam` already has and is
team/leadership-aware, closing the `user_id`-vs-`team_id` gap. Regular
members and teams with another leader are unaffected.

## Verification

- `ENABLE_SAAS=true ./gradlew :saas:compileJava` — passes.
- Manual: with a paid team leader, accepting an invite to another team
should now be rejected with the message above; verify a non-leader
member can still accept.

## Note

This prevents *new* orphans. Any teams already orphaned by this bug
(memberless, still subscribed) would need a one-off reconciliation —
happy to follow up with a query/cleanup if useful.
2026-06-11 20:10:21 +01:00
EthanHealy01 33026e1a82 update saas onboarding (#6619)
<img width="1002" height="487" alt="Screenshot 2026-06-11 at 6 20 10 PM"
src="https://github.com/user-attachments/assets/5ee3cfc2-6c4f-4b35-9586-ef45fa216c6a"
/>
2026-06-11 19:39:02 +01:00
Anthony Stirling 1d598d5caa MCP OAuth discovery fix + Supabase consent page (#6608) 2026-06-11 18:30:49 +01:00
ConnorYoh 9e5fe2f4ca fix(payg): attribute policy runs to the owner so usage is charged (#6620)
## Problem

A policy ran over a file but the owner's **free usage was never
consumed**.

A policy run executes on a **background virtual thread**
(`PolicyEngine.submit` → `asyncExecutor`), and Spring's
`SecurityContextHolder` is thread-local — so the worker thread has no
identity. When `PolicyExecutor` → `InternalApiClient.post` resolves the
tool-call API key via `UserService.getCurrentUsername()`, it finds
nothing and falls back to the **`INTERNAL_API_USER`** key. The loopback
tool calls then authenticate as that system account, so
`PaygChargeInterceptor` attributes the charge to *its* team (or none) —
the real owner's free grant is untouched. Folder-watch / scheduled
triggers are even further removed (fired from a background watch loop
with no request context at all).

The charging *mechanism* was fine (AUTOMATION, multipart,
`openProcess`); only the **attribution** was wrong.

## Fix

Propagate the acting identity onto the worker thread using the
**audit-principal MDC key** that `UserService.getCurrentUsername()`
already reads as its documented async fallback (the same mechanism used
for other async jobs). No new plumbing through the executor.

- **`runPolicy`** (stored policies — covers triggers *and* manual
`runWith`) → bill the **policy owner**. `Policy.owner` is the username
stamped at creation, so `getApiKeyForUser(owner)` resolves it.
- **`submit`** (ad-hoc Automate/AI one-offs) → bill the **submitting
user**, captured on the request thread (it doesn't survive the hop to
the worker otherwise).

With the principal set, `InternalApiClient` dispatches each tool call as
that user → the interceptor resolves the right team → free grant draws /
Stripe meters correctly.

## Tests

`PolicyEngineTest`:
- `runPolicyDispatchesToolCallsAsTheOwner` — asserts MDC
`auditPrincipal` == the policy owner at the moment
`InternalApiClient.post` is invoked.
- `adHocRunDispatchesToolCallsAsTheSubmittingUser` — asserts it's the
submitting user for an ad-hoc run.

`:proprietary:test` + `:saas:test` + spotless green; coverage gates met.

## Heads-up (not in this PR)

Once attributed, **automatic folder-watch / scheduled runs consume free
grant (or bill) per file** — set up once, runs forever. That's
automation-is-billable working as intended, but a set-and-forget policy
can drain an allowance fast, so it may warrant a per-policy cap or a
heads-up in the UI. Flagging for a product decision.
2026-06-11 18:28:58 +01:00
Reece Browne 9ee0bc4b32 Policies: enforce on upload or export (#6614)
Follow-up to #6604 (merged). Builds the Security policy out so it
actually enforces, driven from the editor.

## What it does
- **Run on upload or export** — a single choice in the wizard: enforce
when a file is uploaded, or just before it's exported.
- **Output** — enforced result is a **new version** of the file
(default) or a **new file**, with optional filename
prefix/suffix/auto-number ("Output filename" subsection; auto-number
only for new files).
- **Export enforcement** — exporting an export-mode file runs the policy
first and downloads the enforced result; never hard-blocks (on failure
the original downloads). For new-version policies the in-editor file is
versioned too. Covers every export path incl. multi-file ZIP. A toast
(glowing in the policy's accent while it runs) reports progress and
fades after ~10s.
- **Affordances** — a freshly enforced file briefly glows its policy
accent and carries a shield badge.
- **Config tidy-up** — removed the unwired Security setting fields + the
wizard's review step; "Upgrade to enterprise" on locked categories;
category accent in the detail/wizard headers.

## Notes
- Builds on the manual-only (client-driven) policy model from #6587
(`trigger: null`, metadata in `output.options`), adding the `runOn`
field + export-time enforcement.
- The page-editor merge-export (no single source file) enforces +
downloads but doesn't version in place.

## Verification
typecheck (core + proprietary), eslint, prettier; proprietary suite
(105) green; flows checked in-app.
2026-06-11 18:12:01 +01:00
ConnorYoh 5fa5e12c64 fix(saas): show team invitation banner in SaaS web build (#6612)
## Problem

When a user is invited to a team, the SaaS web app shows **no invitation
banner** — even though the pending invite is returned by
`/api/v1/team/invitations/pending` on refresh.

## Root causes

1. **Never rendered in SaaS.** `TeamInvitationBanner` only existed in
`desktop/`, wired solely into `DesktopBannerInitializer`. The SaaS
banner stack rendered only `<UpgradeBanner />`.
2. **Single banner slot.** `BannerContext` holds one node; `setBanner`
replaces it. `TrialStatusBanner` called `setBanner(null)` when there was
no active trial (and re-fired once `trialStatus` resolved async), wiping
any other banner.
3. **Shadowing was too fragile.** A first attempt shadowed the
proprietary `UpgradeBannerInitializer` from the saas layer, but
`vite-tsconfig-paths` resolves the `@app` specifier once at dev-server
start — a newly-added shadow of an already-resolved module isn't picked
up on a browser refresh, only a full restart. So the proprietary
initializer kept running and no invite banner appeared (while the SaaS
team context still fetched + populated the invite, which is why the
pending call was visible).

## Fix

- Add `saas/components/shared/TeamInvitationBanner.tsx` — ported from
desktop, minus the desktop `connectionMode` gate and explicit billing
refresh (SaaS `acceptInvitation` already refreshes credits + session).
- Render it **inline in `saas/routes/Landing.tsx`** next to
`GuestUserBanner` — a new import specifier in an existing file
(HMR-friendly), unambiguously inside `SaaSTeamProvider`, mirroring the
proven `GuestUserBanner` pattern. No dependency on the single banner
slot.
- **Remove `TrialStatusBanner`** (trials are being retired) so it can't
clobber banners. Also drops the stale mention from the stripe-lazy-load
test comment.

## Verification

- `tsc --noEmit -p tsconfig.saas.vite.json`: clean in touched files;
total unchanged from baseline (37 pre-existing, unrelated).
- Manual: pull + verify the Accept/Decline banner appears for an account
with a pending invite.
2026-06-11 18:01:58 +01:00
James Brunton 34ead60194 Kill off agents pane now that we have FAB (#6613)
# Description of Changes
Kill off agents pane now that we have the FAB. Also fixes a bug with the
FAB where it would sometimes fail to render the chat, and fixes a
duplicated entry in the Vite config which was throwing a warning
2026-06-11 17:28:05 +01:00
ConnorYoh 5bc7ae626d fix(payg): cancelled subscription left team gated as subscribed (#6611)
## Problem

A team that **cancelled** its PAYG subscription kept full subscribed
access:

- **UI didn't reflect cancellation** — the Plan tab still rendered the
subscribed view, never the free/upgrade view.
- **Automation wasn't stopped** — automation / AI / API kept running
without ever falling back to the free-grant gate.

## Root cause

`TeamBillingService.compute` decided `subscribed` as:

```java
boolean subscribed =
    subscriptionId != null
        || extOpt.map(PaygTeamExtensions::getStripeCustomerId).filter(s -> !s.isBlank()).isPresent();
```

On cancellation, the `customer.subscription.deleted` webhook calls
`payg_unlink_subscription`, which nulls `payg_subscription_id` but
**deliberately keeps `stripe_customer_id`** (so a future re-subscribe
can reuse the Stripe customer).

`payg_link_subscription` is the **only** writer of
`payg_team_extensions.stripe_customer_id`, and it writes it in the
*same* `UPDATE` as `payg_subscription_id` (on
`customer.subscription.created`). So the customer id is never set before
the subscription id — the "pre-webhook stand-in" the old comment claimed
**cannot happen**. The fallback only ever pinned a team that *ever*
subscribed to `subscribed` forever, because the Stripe customer outlives
the subscription.

Both symptoms are this one flag:
- `PaygWalletController` status → `SUBSCRIBED` vs `FREE`
- `EntitlementService` gate branch → monthly-cap vs free-grant

## Fix

Gate `subscribed` purely on `payg_subscription_id != null`. A cancelled
team now correctly drops to free (UI shows free; billable ops gate on
the one-time grant). This aligns the wallet/entitlement read with the
**meter path** (`JobChargeService.close`), which already gated on
`payg_subscription_id`.

Handles both Stripe cancel modes: "cancel at period end" keeps the sub
`active` (id stays set) until `.deleted` fires at period end → access
through the paid period; immediate cancel fires `.deleted` now → flips
to free now.

**No data migration / backfill** — already-cancelled teams have
`payg_subscription_id = NULL`, so they flip to free as soon as this
ships (within the 30s billing-cache TTL).

## Tests

Adds `TeamBillingServiceTest` — the `subscribed` computation previously
had **no** unit coverage (which is how this shipped). Covers: subscribed
iff subscription id present; **cancelled team (customer id remains,
subscription id null) ≠ subscribed** + free grant survives;
no-subscription/no-customer; no extension row.

`:saas:test` + `:saas:spotlessCheck` green; coverage gates met.

## Not included (optional hardening, can fast-follow)

- Cross-check the synced `stripe.subscriptions.status` to guard a
*missed* `.deleted` webhook leaving `payg_subscription_id` stale.
- Push cache-invalidation from the webhook (currently ≤30s TTL
staleness).
2026-06-11 17:26:58 +01:00
ConnorYoh f16ca4795c fe(payg): remove em dashes from Plan page copy (#6610)
## What

Removes all em dash (`—`) characters from the **user-facing text** on
the Plan page (PAYG section), replacing them with colons, commas, or
restructured punctuation so the copy reads naturally.

## Changes

- `frontend/editor/public/locales/en-GB/translation.toml` — all `payg.*`
strings (this is what actually renders on the page)
- `PaygFree.tsx` — `t()` default fallbacks + the `{" — "}` JSX
benefit-list separators (now `{": "}`)
- `Payg.tsx` — `t()` default fallback for the editor-plan body

## Notes

- The en-dash range separator (`{{start}} – {{end}}`) in the
billing-period string is intentionally **kept** — only em dashes were
targeted.
- JSDoc / code comments containing em dashes were **left unchanged**,
since they aren't rendered text on the page.
<img width="990" height="502" alt="image"
src="https://github.com/user-attachments/assets/13d89b0f-007c-4b4c-b72d-1d912f968bc7"
/>
2026-06-11 16:50:27 +01:00
James Brunton d52c7ced7c Improvements to Stirling Engine to prepare for SaaS release (#6603)
# Description of Changes
- Use pool for postgres connections
- Add ability to require user ID to be set on API calls to the engine
- Add process-wide concurrency cap on AI access (in addition to existing
user caps)
- Allow number of workers (threads) to be specified for stirling engine
- Update env var names to reflect that the DB is not just for RAG
2026-06-11 16:31:35 +01:00
James Brunton 606964ee52 Fix Teams and MCP settings pages (#6605)
# Description of Changes
Remove the Pro guards from the Team settings page and also fix the
styling of the MCP settings screen (the code sections were black text on
black background in light mode)
2026-06-11 16:26:02 +01:00
ConnorYohandReece cf513c255b PAYG: pay-as-you-go billing — metered automation/AI/API + one-time free grant (#6589)
## Summary

Pay-as-you-go (PAYG) billing for Stirling-PDF SaaS. Manual PDF editing
stays free forever; only **automation, AI, and API** usage is metered.
Every team gets a **one-time lifetime free grant** (default 500 PDFs)
before any billing; past that, a team adds a card and pays per metered
document, with a self-set monthly spending cap.

This branch combines and supersedes the in-flight BE (#6574) and FE
(#6579) work plus the SaaS edge functions (Stirling-PDF-SaaS PR, now on
`v3`), hardened into a single reviewable feature after a pre-merge
dead-code/security review.

## Billing model

- **Always free:** manual / JWT web-tool usage is `BYPASSED` — never
metered, no matter where it's triggered.
- **Billable categories:** `AUTOMATION`, `AI`, `API`.
- **One-time lifetime free grant** (`pricing_policy.free_tier_units`,
default 500): never resets, survives subscribing. It gates unsubscribed
teams (billable API calls hard-stop with a 402 once exhausted) and
decides the free-vs-paid split of every job.
- **Subscribed:** paid documents (beyond the grant) are metered to a
Stripe Billing Meter; an optional monthly spending cap degrades billable
categories when reached.
- **Dedup:** the same file pushed through several steps within a
workflow window counts **once** (lineage join), so API/AI chaining on
one file isn't double-charged.

## What's included

**Database** — Flyway migrations `V11`→`V21` with matching Supabase
twins: pricing policy + per-team sidecar (`payg_team_extensions`:
subscription id, Stripe customer, free-grant counter), append-only
`wallet_ledger`, shadow charges, subscription-state RPCs (`V14`), audit
logs (`V15`), billing category (`V16`), one-time lifetime free grant
(`V19`), launch-grant seed (`V20`), drop of the unused
`wallet_category_summary` view (`V21`).

**Charge pipeline** — `PaygChargeInterceptor` (open/join a process,
split the free grant, write the ledger DEBIT), `JobChargeService`
(consume the grant under a row lock, restore it on a first-step refund,
meter only the paid portion on completion), `StaleJobCloser` fallback
(idempotent close → meter).

**Entitlement** — `EntitlementService` (per-team cached snapshot:
grant-gated for free teams, monthly-cap-gated for subscribed) +
`EntitlementGuard` (401 `SIGNUP_REQUIRED` / 402 `FEATURE_DEGRADED` /
`PAYG_LIMIT_REACHED`).

**Metering** — `PaygMeterReportingService` writes a durable
`payg_meter_event_log` row around every POST to the `meter-payg-units`
edge fn (pending → posted/failed); `PaygMeterReconcileScheduler` retries
unposted events under the same idempotency key inside Stripe's 24h dedup
window.

**Billing facts** — `TeamBillingService` reads the synced `stripe.*`
mirror (subscription window, per-document rate; the unsubscribed-team
estimate resolves the rate by Price `lookup_key = plan:processor`).

**Wallet API** — `PaygWalletController`: `GET /api/v1/payg/wallet`,
`PATCH /api/v1/payg/cap`.

**Frontend** — PAYG Plan page (two-card free layout + subscribed views),
`useWallet`, upgrade modal with lazy-loaded Stripe Embedded Checkout and
a shared `SpendCapControl`, customer-portal link, 402/401 interceptor
toast, en-GB i18n. (Per-member usage shows each teammate's spend; the
activity feed is behind a flag until polished.)

**SaaS edge functions** (`Stirling-PDF-SaaS` `v3`) —
`create-checkout-session`, `create-payg-team-subscription`,
`create-customer-portal-session`, `meter-payg-units`,
`payg-subscription-webhook`, `stripe-sync`, plus the stripe-sync
`migrate` + scoped-`backfill` scripts. All price lookup is DB-driven (no
`STRIPE_PAYG_PRICE_ID_*` env vars).

## Release prerequisites (prod)

1. Apply Flyway migrations (`V11`→`V21`) and the Supabase migration
twins.
2. Stripe Sync Engine: run `stripe-sync:migrate`, then a **scoped**
backfill — `product`, `price`, `customer`, `subscription` only (not
`all`, which rate-limits).
3. Register 2 PAYG webhook endpoints (each its own signing secret):
`stripe-sync` (product/price/customer/subscription `.*`) and
`payg-subscription-webhook` (`customer.subscription.created`/`.deleted`
drive state; `.updated` + `invoice.*` observed). Keep the legacy
`stripe-webhook` only if credits/self-hosted flows still run.
4. Stripe Billing Meter: `event_name = payg_doc_units`, value key
`processed_documents`.
5. Env: `PAYG_METER_ENDPOINT` + `SUPABASE_EDGE_FUNCTION_SECRET`
(backend); the webhook signing secrets (edge fns). The default pricing
policy must point at the PAYG Stripe Price(s); `V20` seeds
`free_tier_units = 500`.

## Testing

- `:saas:test` green, `:saas:spotlessCheck` clean, edge-fn Deno tests
green, FE saas typecheck clean (the remaining errors are pre-existing
`proprietary/*` + `prototypes/*`, untouched here). Cucumber shadow-mode
suite + CI workflow included.

## Pre-merge review

An independent dead-code/security pass came back **clean on security**
(team-derived authz / no IDOR, leader-only cap mutation, no
billing-category downgrade, dev/mock hooks gated to
`import.meta.env.DEV` + `/dev/`, no secrets/injection, fail-open
metering by design). The dead/unwired code it flagged has been removed
in this branch (unenforced sub-cap control, an unused JDBC DAO + its
view, dead methods).

## Follow-ups (tracked, not blocking)

- **Enforce per-member sub-caps** — the control was removed because it
read for display but never gated a request; the per-member usage display
and `cap_units` column are retained for when enforcement is wired.
- **API/AI chaining billing model + `ProcessType` enum** — confirm
same-file dedup covers API chaining; define per-tool AI charging; decide
whether the unused enum values stay.
- **Activity feed** — hidden behind a flag until the meter-event surface
is polished.

---------

Co-authored-by: Reece <reece@stirlingpdf.com>
2026-06-11 15:56:01 +01:00
EthanHealy01andAnthony Stirling 88adb7adad create agent (#6520)
Added the create agent. Use [these
prompts](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/blob/main/docgen/backend/default_templates/sample_prompts.md)
to test or try your own :)

Here’s the one I use

```
Hey, I need to generate an employee expense report for reimbursement.
Company: Summit Consulting Partners Company address: 88 Riverside Plaza, Suite 1400, New York, NY 10069 Accounting department email: expenses@example.com
Employee details:
* Employee Name: Michael Tran
* Employee ID: EMP-1047
* Department: Client Services
* Report Date: January 20th, 2026
* Reporting Period: January 5th, 2026 – January 16th, 2026
* Manager Approver: Laura Simmons
Trip purpose: Client onsite meetings with Atlantic Energy Solutions in Boston, MA.
Expense items:
* Flight (NYC to Boston roundtrip) — $325.40 — January 5th, 2026 — Airline ticket
* Hotel (3 nights at Harborview Hotel) — $822.75 — January 5th-8th, 2026
* Taxi from airport to hotel — $48.00 — January 5th, 2026
* Client dinner (3 attendees) — $186.20 — January 6th, 2026
* Parking at JFK Airport — $72.00 — January 5th-8th, 2026
* Breakfast (per diem not used) — $18.50 — January 7th, 2026
* Uber to client office — $22.10 — January 7th, 2026
* Printing + presentation materials — $46.90 — January 8th, 2026
* Lunch with client — $39.75 — January 8th, 2026
* Office supplies (notebooks, pens) — $27.60 — January 10th, 2026
* Mileage reimbursement (client visit in NJ, 42 miles @ $0.67/mile) — $28.14 — January 14th, 2026
* Team lunch meeting (internal) — $64.30 — January 15th, 2026
Reimbursement method should be direct deposit.
Add a notes section stating: "All receipts attached. Expenses are business-related and comply with company travel policy."
```

---------

Co-authored-by: Anthony Stirling <77850077+frooodle@users.noreply.github.com>
2026-06-11 14:18:13 +00:00
Reece Browne 11ab762f57 feat(policies): config refinements + new-version output (post-#6598) (#6604)
Follow-up to #6598 (squash-merged into `SaaS`). These are the policy
refinements made after that merge, against the current `SaaS` tip.

## Changes
- **Simplify Security config + plain-language info buttons** — Redact
config reduced to the PII field; Sanitise has no config
(JavaScript-removal only) with a non-technical info button; per-tool
info buttons reworded to match the tool-steps style.
- **Hide 'Flatten PDF pages to images' from the watermark policy
config** — new `PolicyWatermarkConfig` wrapping the watermark settings
with the flatten checkbox gated off.
- **Flatten-to-image on by default for redact + watermark** — both
normalise `convertPDFToImage: true` on mount.
- **Self-heal a stale backing folder** — `ensurePolicyFolder` recreates
a backing folder whose `folderId` no longer resolves (preferring the
backend's stored automation), instead of hanging Edit Settings on a
permanent "Loading…".
- **Version the input file on 'new version' output mode** — completed
runs whose policy output mode is `new_version` replace the input file
with a versioned child (origin tool `automate`) rather than adding a
separate file; falls back to a new file if the input is gone.
`outputMode` is plumbed through `PolicyState`, the local-cache default,
and backend reconciliation.

## Verification
- `typecheck:proprietary` + `typecheck:core` clean
- policy + hooks vitest: 17 passing
- eslint + prettier clean on all changed files
2026-06-11 14:45:22 +01:00
James Brunton 68e031ac55 Policies tidying (#6587)
# Description of Changes
* Improve typing of API (breaking change but unreleased, frontend also
updated in this PR)
* Add ownership concept to policies
* De-AI the comments
* Update the `task dev:saas` rule to spawn the engine as well
2026-06-11 13:20:01 +01:00
Anthony Stirling c722b9f6ad fix: MCP copy buttons read as proper buttons in dark mode
Subtle/gray compact buttons rendered as low-contrast floating text;
use the default variant (adaptive surface+border) idle, light teal when
copied.
2026-06-10 17:26:43 +01:00
Anthony Stirling 36c68fb69e fix: doubled base path in mobile-scanner QR URL
A configured frontendUrl/server_url already includes the subpath (e.g.
/bpp), but the code also applied withBasePath, producing /bpp/bpp/...
Append the route directly to a configured URL; reserve withBasePath for
the bare-origin fallback. Matches the ShareFileModal convention.
2026-06-10 17:21:42 +01:00
Anthony Stirling d3c359f923 reword MCP usage tip to reference the API and Automation 2026-06-10 16:38:16 +01:00
Anthony Stirling 4947ab12fd remove the 'What your assistant can do' tool-category badges from MCP section 2026-06-10 16:33:48 +01:00
Reece Browne 8dde4262ec feat(policies): backend-driven policy enforcement (frontend) (#6598)
## Summary
Adds the **Policies** feature (proprietary, behind the
`POLICIES_ENABLED` flag): backend-driven enforcement that runs a fixed
tool pipeline on documents, docked in the right tool sidebar alongside
Tools.

## Highlights
- **Policy catalog** — 5 categories; **Security** is wired (redact PII +
sanitize), the others are marked "Coming soon".
- **Backend as source of truth** — policies persist via the Policies
engine (`/api/v1/policies`), one policy per category, with a local cache
+ offline fallback.
- **Auto-run** — enabled policies run on every uploaded file: dispatch →
poll → import outputs into the workspace.
- **Security redact config** — PII preset dropdown + custom word/regex
entry + advanced options; tool params map to the backend endpoint
fields.
- **Activity feed** with retry on failures; **file badges** showing
which policies ran on a file (sidebar + files page), tinted to the
policy colour.
- Reuses the **Watched Folders** engine for each policy's backing
folder; policy-owned folders are filtered out of the Watched Folders UI.

## Notes
- Gated by `POLICIES_ENABLED` (true in proprietary, false in core) —
unreachable in the open-source build.
- Frontend-only diff; depends on the backend Policies engine and the
merged Watched Folders feature.
2026-06-10 15:57:08 +01:00
James Brunton ebc28b0a14 Add team settings to SaaS (#6601)
# Description of Changes
Add team settings UI to SaaS, which is currently only available in
desktop. It'd be nice to refactor this so they're more shared, but
they're slightly different so needs to be done with some care. Leaving
for followup work.
2026-06-10 15:54:18 +01:00
Anthony Stirling 56862cc1d3 Merge branch 'main' into SaaS 2026-06-10 15:51:43 +01:00
EthanHealy01 9b877d4f8d Move agent section to fab (#6597) 2026-06-10 15:47:47 +01:00
Reece Browne da4b84962c drop type-aware ESLint to stop the lint OOM (#6602) 2026-06-10 15:44:52 +01:00
Anthony Stirling d6306f51e1 fix: no blue disc behind the sidebar profile picture
Keep the colored background only for the initials fallback; a real
photo fills the circle with a transparent backing.
2026-06-10 15:05:29 +01:00
Anthony Stirling 9a1804ce04 Merge branch 'main' into SaaS 2026-06-10 14:58:44 +01:00
Anthony Stirling 611468b972 Add SaaS MCP usage tab (#6590) 2026-06-10 14:58:33 +01:00
EthanHealy01 5fca2f199a Feature/pdf ingestion jpdfium (#6525) 2026-06-10 14:51:41 +01:00
Anthony Stirling be0db3fd8a fix: show profile picture in the FileSidebar bottom bar
The home page's bottom-left settings button is FileSidebar's bottom
bar, which hardcoded an initials circle - the avatar work in
useConfigButtonIcon only affects the QuickAccessBar rail, which the
home page doesn't render. Add a layered useProfilePictureUrl hook
(core stub returns null; saas returns the auth context URL) and render
the picture inside the existing avatar circle, falling back to the
initial when absent or on image load failure.
2026-06-10 14:49:16 +01:00
EthanHealy01 2aa6768921 show chat progress and other UX improvements (#6576) 2026-06-10 14:47:57 +01:00
Anthony Stirling 90bda6b4b4 fix: User principal was discarded by the resource server re-authentication
The saas chain authenticates bearer requests twice:
SupabaseAuthenticationFilter builds an EnhancedJwtAuthenticationToken
with the resolved User principal, but BearerTokenAuthenticationFilter
(oauth2ResourceServer) then re-authenticates the same token through the
static toAuthentication converter and overwrites the SecurityContext
with a token whose principal is the raw Jwt - so storage endpoints kept
returning 401 "Unsupported user principal" despite the principal fix.

Carry the User across in the converter: when the context already holds
an EnhancedJwtAuthenticationToken for the same subject with a User
principal, attach that User to the converter-built token. No extra DB
lookups; anonymous sessions and API-key auth unchanged. Covered by new
unit tests (carry, no-context, subject mismatch).
2026-06-10 14:37:12 +01:00
Anthony Stirling 5b412c0fed cleanup: trim oversized comments across recent SaaS fixes
Reduce multi-paragraph comment blocks to short two-line notes and drop
history-style references; no behaviour changes.
2026-06-10 14:30:29 +01:00
Anthony Stirling bf18af4708 fix: show user avatar on the home page settings button
The bottom-left settings button and the settings page both read
profilePictureUrl, but only the settings page had a fallback (initials
avatar) - the button silently fell back to a gear. The URL itself was
usually null because fetchProfilePicture raced the background OAuth
avatar sync with a fixed 500ms delay and never retried, and a missing
bucket object simply resolved to null.

- useConfigButtonIcon: fall back to the same initials avatar as the
  settings page instead of the gear when no picture URL is available.
- UseSession: fetch the profile picture when syncOAuthAvatar settles
  (init and SIGNED_IN) instead of after an arbitrary 500ms.
- fetchProfilePicture: when the bucket copy is missing, fall back to
  the OAuth provider's own photo URL so the picture shows immediately
  on first login - unless the user explicitly uploaded/removed a
  picture (metadata source 'upload'), preserving the remove flow.
2026-06-10 14:25:19 +01:00
EthanHealy01andAnthony Stirling f15e405759 changes to the login and signup, similar to in the saas repo (#6577)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-10 13:49:27 +01:00
Anthony StirlingandClaude Opus 4.8 d29059e6fb fix: storage APIs 401'd valid Supabase sessions (principal type mismatch)
FileStorageService.requireAuthenticatedUser and
FolderService.requireAuthenticatedUser authorize via
'principal instanceof User', but EnhancedJwtAuthenticationToken extends
JwtAuthenticationToken whose principal is the decoded Jwt - so every
/api/v1/storage/* request 401'd for JWT users AFTER Spring Security had
already authenticated them. This persistent 401-with-valid-session was
the trigger feeding the frontend login loop.

Attach the filter-resolved local User as the token principal for full
accounts (User implements UserDetails, matching the form-login
convention every shared instanceof check expects). Anonymous sessions
keep the raw Jwt principal, preserving their existing exclusions. All
other principal consumers verified safe: AuthenticationUtils checks
instanceof User first, extractSupabaseId/CreditController/Team
SecurityExpressions switch on the authentication type, not the
principal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:44:22 +01:00
Anthony StirlingandClaude Opus 4.8 e7bbbb4702 fix: make the 401 login redirect loop structurally impossible
Audit of every code path that can produce the login->/->login cycle
found the observed loop was one instance of a repeatable class: any
automatic API call that persistently 401s while the Supabase session is
valid triggers httpErrorHandler's hard redirect to /login, which sees
the valid session and bounces back. Close the class, not just the
instance:

- saas apiClient: a 401 that survives a refresh-and-retry means the
  backend rejected a valid token (authz bug / wrong origin), not an
  expired session - never redirect to /login for it. Also fix the stale
  publicEndpoints entry ('endpoints-enabled' matched nothing; the real
  routes are endpoints-availability and endpoint-enabled).
- httpErrorHandler: sessionStorage loop breaker - if a 401 redirect
  already fired within 10s, suppress the repeat instead of cycling.
- Guard the remaining unflagged automatic callers: /api/v1/credits
  (fires on session init and TOKEN_REFRESHED), endpoints-availability
  (fires on app load), and ui-data/login (auto-called when a stale
  stirling_jwt is present).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:44:21 +01:00
Anthony StirlingandClaude Opus 4.8 247ef6313c fix: stop login loop caused by unguarded folder-sync 401
The deployed app looped /login -> / -> /login forever: Login sees a
valid Supabase session and navigates to /, the global FolderProvider
pulls GET /api/v1/storage/folders, the backend rejects it with 401, and
the global error handler hard-redirects back to /login?from=/bpp.

fileSyncService's /api/v1/storage/files pull already opts out via
suppressErrorToast + skipAuthRedirect, so its 401 fails silently;
folderSyncService.list() passed neither flag, so its 401 fell through to
the redirect. Add the same flags - FolderContext.pullFromServer already
handles 4xx locally (flips serverReachable, suppresses the banner).

Note: the underlying 401 on /api/v1/storage/* with a valid session is a
backend/deployment issue (storage endpoints rejecting the Supabase
token); this change makes the frontend resilient so it degrades to
"folder sync unavailable" instead of an auth loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:13:20 +01:00
Anthony StirlingandClaude Opus 4.8 7f7c865888 fix: stop unauthenticated storage calls on /login + fix subpath manifest 404
Two issues seen on the hosted /bpp login screen:

1. GET /api/v1/storage/folders fired (and 401'd) on the login page. The
   global FolderProvider pulls from the server whenever
   appConfig.storageEnabled is true, with no auth gate, so it hits the
   authenticated storage API before the user has signed in. Skip the pull
   on auth routes (/login, /signup, /auth/*, /invite, /reset-password),
   mirroring the existing LicenseContext / AppConfigContext guards. Tests
   wrap FolderProvider in MemoryRouter (now uses useLocation).

2. manifest.json and modern-logo/favicon.ico 404'd from the domain root
   instead of /bpp/. vite base for RUN_SUBPATH deploys was "/bpp" with no
   trailing slash, so <base href="/bpp"> made the browser resolve relative
   links against the parent (root). Use "/bpp/"; getBasePath() strips the
   trailing slash, so BASE_PATH, routing and asset URLs are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:56:48 +01:00
James Brunton 3675db5907 Add new triggers, sources and sinks to policies (#6543)
# Description of Changes
Add new triggers:
- Schedule (fires every X amount of time)
- Folder watch (fires whenever the OS tells us a folder has a new file
in it; on Mac this is technically a 2s schedule but that's just how Java
implements it)

Add new sources:
- Folder (reads from this directory)

Add new sinks:
- Inline (stores in FileStorage)
- Folder (stores in specified directory)

Still want to do S3 buckets and web hooks and stuff, but they can come
in a future PR. I'm hoping this should make it sufficient to be able to
integrate with processing folders frontend etc. I've also changed it so
that policies can have multiple sources and triggers at once, which
seems like it might be useful.
2026-06-10 10:55:29 +00:00
Anthony Stirling 2101b4028c Merge branch 'main' into SaaS 2026-06-10 11:42:25 +01:00
Anthony StirlingandClaude Opus 4.8 06476ea69e fix(saas): collapse duplicate Supabase client to one GoTrueClient
The console warned "Multiple GoTrueClient instances detected in the same
browser context" and storage endpoints (/api/v1/storage/folders,
/files) kept 401ing even after a successful token refresh.

Cause: the SaaS bundle instantiated TWO Supabase clients on the same
sb-<ref>-auth-token storage key. :saas/auth/supabase.ts creates the
primary client (used by UseSession + apiClient), while billing /
licensing / user-management code imports @app/services/supabaseClient,
which fell through to :proprietary/services/supabaseClient.ts and called
createClient() again. Each client runs its own autoRefreshToken timer,
so they rotate the refresh token out from under each other → "Already
Used" refresh failures and spurious 401s, plus a residual /login flash.

Add a :saas override of @app/services/supabaseClient that re-exports the
single instance from @app/auth/supabase. The path mapping
(@app/* → src/saas/* → src/proprietary/* → src/core/*) now resolves
every consumer to the same client, so the :proprietary createClient() is
never bundled in the SaaS build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:26:27 +01:00
Reece Browneandaikido-pr-checks[bot] e2536daeb8 Feature/v2/smartfolders rebuild (#6480)
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
2026-06-10 11:18:14 +01:00
Anthony StirlingandClaude Opus 4.8 1135bd9b63 fix(saas): stop login→logout→login bounce on cold load
On returning to the app with an expired Supabase access token, bootstrap
requests fired with the stale token and 401'd before Supabase finished
refreshing. The global 401 handler then hard-redirected to
/login?from=… (a full window.location navigation), and once the refresh
landed the app sent the user straight back in — the login/logout/login
flicker.

Two holes in the SaaS apiClient response interceptor caused it:

1. "public" endpoints (e.g. /api/v1/config/app-config) skipped the
   refresh-and-retry path. The backend 401s any expired Bearer token
   regardless of route, so those bootstrap calls 401'd and fell through
   to handleHttpError, which redirected to /login. Now public endpoints
   also refresh-and-retry, and a 401 on a public endpoint sets
   skipAuthRedirect so it can never trigger the global login redirect.

2. Concurrent 401s each called supabase.auth.refreshSession()
   independently. Supabase rotates the refresh token on first use, so
   the racing refreshes failed with "Invalid Refresh Token: Already
   Used" and bounced the app even though the session was recoverable.
   Refreshes are now de-duplicated through a single in-flight promise.

Existing apiClient unit tests (refresh-and-retry on protected 401, bare
/login redirect on genuine refresh failure) are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:13:28 +01:00
Anthony Stirling bbfe29c2ef Merge remote-tracking branch 'origin/main' into SaaS
# Conflicts:
#	frontend/editor/src/core/components/shared/AppConfigModal.tsx
2026-06-10 10:51:06 +01:00
James Brunton e0fc5061de Delete dead translations (#6581)
# Description of Changes
Adds all the missing translations that I could find (they're all dynamic
ones that the existing test can't detect are required) and adds a new
test to find as many unused translations as possible. The test has an
ignore list for translations that are used, but dynamically so the test
can't find them (most of the settings UI translations are built up
dynamically like that).

This PR is scoped to just include en-GB translation changes, since
that's the main supported language. We'll need to do a translation PR to
trim all the dead keys from the other languages, and add the missing
ones.
2026-06-10 09:49:00 +00:00
Anthony Stirling 3ecd95b779 Add MCP server with OAuth/API-key auth (#6570)
Adds an optional MCP server (proprietary module) that exposes Stirling's
PDF operations and AI capabilities to MCP clients. Off by default, zero
footprint when disabled.

### What
- New `/mcp` endpoint: streamable-HTTP + JSON-RPC 2.0; 8 tools
(describe_operation, pages/convert/misc/security category tools, AI,
upload, download).
- Runs real operations over an internal loopback; results returned
inline as base64 (small) or by fileId (large).

### Auth (two modes)
- OAuth2 resource server: RFC 9728 protected-resource metadata, RFC 8707
audience binding, JWKS, `mcp.tools.read/write` scopes; binds each token
to a provisioned Stirling account.
- API-key mode: reuses Stirling per-user `X-API-KEY` (no IdP needed).

### Security
- Per-user file ownership in FileStorage: async/queued writes scoped to
the submitting user; legacy/owner-less files stay readable.
- Admin allow/block list controls which operations are exposed.
- Python engine gated behind a shared secret (`X-Engine-Auth`).
- MCP filter chain is isolated and cannot weaken the main app's
security.
- Hardened: no upstream error-body leakage, log injection sanitized,
fileId path/sidecar enumeration blocked.

### Config / footprint
- Disabled by default (`mcp.enabled=false`); all beans
`@ConditionalOnProperty`.
---

## 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-10 09:46:25 +00:00
ConnorYoh 84aca12055 PR-S4: shadow-mode hardening (review follow-ups) (#6523)
## What this PR does

Bundles the **low-risk polish items** from the [multi-agent review of
#6519](https://github.com/Stirling-Tools/Stirling-PDF/pull/6519). Each
change is independent, mechanical, and ships with focused unit-test
coverage.

The medium-severity items (\`?async=true\` OUTPUT recording,
JSON-consumes endpoint coverage, SpringBootTest harness) are tracked
separately in [\`notes/PAYG_DESIGN.md\` §7.5
PR-S4](https://github.com/Stirling-Tools/Stirling-PDF/blob/payg-s4-hardening/notes/PAYG_DESIGN.md)
— they need design decisions + bigger infrastructure work, so this PR
sticks to the mechanical wins.

Stacked on #6519. When that merges to main, this rebases cleanly — no
code changes.

## Changes

| Area | What | Why |
|---|---|---|
| **\`tool_id\` becomes route pattern** |
\`PaygChargeInterceptor.resolveToolId()\` prefers
\`HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE\` over
\`request.getRequestURI()\`. Truncates to 128 + WARN log +
\`payg.filter.errors\` increment when truncation fires. | Audit rollups
aggregate by endpoint instead of by every individual request's
path-variable / matrix-param variant. Silent truncation now louder. |
| **Direct PDF magic-byte check** | \`PaygOutputExtractor.extract()\`
magic-checks the body even for direct \`application/pdf\` responses. |
Asymmetric with the ZIP-entry path which always magic-checks. A tool
that emits \`application/pdf\` for a JSON / HTML payload would otherwise
pollute \`job_artifact_hash\`. |
| **DESKTOP_APP detection** | \`X-Stirling-Client: desktop\` header →
\`JobSource.DESKTOP_APP\`. | The enum value was unreachable from
\`determineSource()\`; Tauri shell traffic was mis-classified as WEB. No
anti-spoof — V12 step limits are identical for WEB/DESKTOP_APP so the
worst-case abuse value is zero today. |
| **\`max-bytes\` sensible default** | 500 MiB instead of \`null\`
(unbounded). | Covers the largest realistic Stirling responses (full
split-to-ZIP on a 1000-page document) while preventing pathological
cases from tying up the interceptor for minutes. Set to \`null\` to
disable. |
| **\`BufferedOutputStream\` for spill** | Wraps the spill
\`OutputStream\` in 64 KiB \`BufferedOutputStream\`. | Previously every
Tomcat chunk (default 8 KiB) was a separate syscall. Big spilled
responses get a syscall-bound speedup. |
| **Duration timer per phase** | \`payg.filter.duration\` tagged
\`phase=preHandle\` vs \`phase=afterCompletion\`. | Two distinct latency
distributions were blended into one histogram; hard to alert on. |

## Tests

| Test | What it covers |
|---|---|
|
\`PaygOutputExtractorTest.pdfContentType_butBodyMissingPdfMagic_returnsEmpty\`
| New direct-PDF magic-byte gate. |
|
\`PaygChargeInterceptorTest.preHandle_desktopClientHeader_setsJobSourceDesktopApp\`
| New \`X-Stirling-Client: desktop\` → DESKTOP_APP path. |
|
\`PaygChargeInterceptorTest.preHandle_toolId_prefersBestMatchingPattern\`
| Route pattern wins over URI when both are set. |
|
\`PaygChargeInterceptorTest.preHandle_toolId_truncatesAndCountsWhenLongerThan128\`
| Oversized values truncate + increment errors counter. |

Full saas suite green (210 tests), coverage targets met.

## What's NOT in this PR (deliberately)

- **\`?async=true\` OUTPUT recording.** The JobExecutorService returns a
synchronous \`JobResponse{jobId}\` body before the async tool actually
runs; \`afterCompletion\` fires too early. Needs a design decision:
short-circuit PAYG when \`async=true\` OR hook into \`TaskManager\`
completion. Tracked in PR-S4 design doc.
- **JSON-consumes endpoint coverage.** The
\`MultipartHttpServletRequest\` cast skips endpoints with \`consumes =
APPLICATION_JSON_VALUE\` (e.g.
\`ConvertPdfJsonController.exportPartialPdf\`). Fix is either extract a
request-body hash for JSON or add a CI lint forbidding non-multipart
\`@AutoJobPostMapping\`. Design discussion needed.
- **SpringBootTest harness for filter + interceptor wiring.** Saas
module doesn't have one yet. Separate work — PR-S3 takes a different
approach (docker-compose + Behave); a SpringBootTest layer would be
additive in-process coverage.

These are tracked in \`notes/PAYG_DESIGN.md §7.5\` so they don't slip.

## Tracked in

\`notes/PAYG_DESIGN.md\` §7.5 PR-S4.
2026-06-10 09:08:43 +00:00
Anthony Stirling 92376b7382 fix: prettier format on AppConfigModal 2026-06-08 21:28:13 +01:00
Anthony Stirling 1d5ce8a1d2 chore: shorten verbose block comments across SaaS branch 2026-06-08 18:38:00 +01:00
Anthony Stirling 8b2baaf0a0 Merge remote-tracking branch 'origin/saas-docker-split' into SaaS 2026-06-08 18:10:02 +01:00
Anthony Stirling d9651f7065 fix(engine): match Dockerfile layout to root Taskfile dir: engine 2026-06-08 18:02:02 +01:00
Anthony Stirling 4cd03be87a fix: send Supabase token on raw fetch in SaaS chat 2026-06-08 16:41:09 +01:00
Anthony Stirling 02d923f378 chore: remove env var debug from vite.config 2026-06-08 16:29:47 +01:00
Anthony Stirling e7d3430134 merge: pull latest main into SaaS 2026-06-08 16:28:14 +01:00
Anthony Stirling 4b2be58fab debug: fuzzy-match env var names that look like VITE_API_BASE_URL 2026-06-08 16:18:19 +01:00
Anthony Stirling 290c8c2c8b debug: enumerate VITE_/RUN_ env var names in build log 2026-06-08 16:06:56 +01:00
Anthony Stirling 90d6ecd7e1 debug: log env vars at build, write build-info.txt with masked markers 2026-06-08 15:55:40 +01:00
Anthony Stirling a0b7daca52 trigger: rebuild after VITE_API_BASE_URL scope fix 2026-06-08 13:37:30 +01:00
Anthony Stirling 0b575ed841 fix: respect BASE_PATH in AI chat fetch and pdfjs worker assets 2026-06-06 21:21:24 +01:00
Anthony Stirling 940cb2fc44 chore: trigger Cloudflare deploy 2026-06-06 19:26:08 +01:00
Anthony Stirling 9da0a0d020 fix: respect BASE_PATH in redirects, comparisons, and cookie consent paths 2026-06-06 19:20:07 +01:00
Anthony Stirling 0b944a29a7 Prefer Maven Central over jboss/shibboleth mirrors for resilience 2026-06-03 09:10:08 +01:00
Anthony Stirling 58aeba2bf7 Add backend-only and SaaS-aware frontend Dockerfiles 2026-06-03 09:03:58 +01:00
1772 changed files with 231930 additions and 86919 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')
+13 -1
View File
@@ -1,6 +1,8 @@
build: &build
- build.gradle
- app/(common|core|proprietary)/build.gradle
- Taskfile.yml
- .taskfiles/backend.yml
openapi: &openapi
- *build
@@ -38,6 +40,9 @@ project: &project
- frontend/**
- docker/**
- scripts/RestartHelper.java
- Taskfile.yml
- .taskfiles/backend.yml
- .taskfiles/docker.yml
- scripts/db-migration/**
- .github/workflows/db-migration-test.yml
@@ -55,6 +60,9 @@ frontend: &frontend
- scripts/summarize_type3_signatures.py
- scripts/type3_to_cff.py
- scripts/update_type3_library.py
- Taskfile.yml
- .taskfiles/frontend.yml
- .taskfiles/e2e.yml
# Files that affect the Tauri desktop bundle. Gate the multi-OS Tauri build
# job on changes to any of these.
@@ -66,6 +74,8 @@ tauri: &tauri
- frontend/package-lock.json
- frontend/editor/vite.config.ts
- .github/workflows/tauri-build.yml
- Taskfile.yml
- .taskfiles/desktop.yml
# Files that affect the AI engine (Python tool models, fixers, tests). Gate
# the engine validation job on changes to engine sources or to the Java
@@ -74,6 +84,8 @@ engine: &engine
- engine/**
- app/(common|core|proprietary)/src/main/java/**
- .github/workflows/ai-engine.yml
- Taskfile.yml
- .taskfiles/engine.yml
licenses-frontend: &licenses-frontend
- ".github/workflows/frontend-backend-licenses-update.yml"
@@ -102,4 +114,4 @@ proprietary: &proprietary
- configs/settings.yml.template
- build.gradle
- app/proprietary/build.gradle
- .github/workflows/build-enterprise.yml
- .github/workflows/build-enterprise.yml
+3 -3
View File
@@ -13,7 +13,7 @@ Usage:
"""
# Sample for Windows:
# python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-GB/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml
# python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-US/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml
import argparse
import glob
@@ -211,7 +211,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
)
continue
if basename_current_file == basename_reference_file and locale_dir == "en-GB":
if basename_current_file == basename_reference_file and locale_dir == "en-US":
continue
if (
@@ -308,7 +308,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("")
report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/editor/public/locales/en-GB/translation.toml)"
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-US/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/editor/public/locales/en-US/translation.toml)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
@@ -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
+2 -2
View File
@@ -239,7 +239,7 @@ jobs:
- name: Build and push V2 image (Depot)
if: env.USE_DEPOT == 'true' && steps.check-image.outputs.exists == 'false'
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
@@ -293,7 +293,7 @@ jobs:
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
SYSTEM_DEFAULTLOCALE: en-GB
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}"
UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Embedded Architecture"
UI_APPNAMENAVBAR: "V2 PR#${{ needs.check-pr.outputs.pr_number }}"
@@ -222,10 +222,10 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.5.1
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Run Gradle Command
run: |
if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then
@@ -256,7 +256,7 @@ jobs:
- name: Build and push PR-specific image (Depot)
if: env.USE_DEPOT == 'true'
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
@@ -285,7 +285,7 @@ jobs:
- name: Build and push engine image (Depot)
if: env.USE_DEPOT == 'true' && needs.check-comment.outputs.enable_prototypes == 'true'
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: ./engine
@@ -388,7 +388,7 @@ jobs:
environment:
DISABLE_ADDITIONAL_FEATURES: "${DISABLE_ADDITIONAL_FEATURES}"
SECURITY_ENABLELOGIN: "${LOGIN_SECURITY}"
SYSTEM_DEFAULTLOCALE: en-GB
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: "Stirling-PDF PR#${PR_NUMBER}"
UI_HOMEDESCRIPTION: "PR#${PR_NUMBER} for Stirling-PDF Latest"
UI_APPNAMENAVBAR: "PR#${PR_NUMBER}"
+2 -2
View File
@@ -43,10 +43,10 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.5.1
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Regenerate tool models
run: task engine:tool-models
+2 -2
View File
@@ -58,11 +58,11 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.5.1
cache-disabled: true
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Check Java formatting (Spotless)
# Runs once per matrix combination - pick the cheapest leg
# (core - no proprietary, no saas) so we don't wait for the
+1 -1
View File
@@ -78,7 +78,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Install Playwright (chromium only)
run: task e2e:install -- chromium
- name: Build frontend (needed for playwright's vite preview webServer)
+2 -2
View File
@@ -40,11 +40,11 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.5.1
cache-disabled: true
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Check licenses for compatibility
run: task backend:licenses:check
env:
+2 -2
View File
@@ -45,11 +45,11 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.5.1
cache-disabled: true
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Generate OpenAPI documentation
run: task backend:swagger
env:
+6 -6
View File
@@ -166,16 +166,16 @@ jobs:
// Determine reference file
let referenceFilePath;
if (changedFiles.includes("frontend/editor/public/locales/en-GB/translation.toml")) {
if (changedFiles.includes("frontend/editor/public/locales/en-US/translation.toml")) {
console.log("Using PR branch reference file.");
const { data: fileContent } = await github.rest.repos.getContent({
owner: prRepoOwner,
repo: prRepoName,
path: "frontend/editor/public/locales/en-GB/translation.toml",
path: "frontend/editor/public/locales/en-US/translation.toml",
ref: branch,
});
referenceFilePath = "pr-branch-translation-en-GB.toml";
referenceFilePath = "pr-branch-translation-en-US.toml";
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
fs.writeFileSync(referenceFilePath, content);
} else {
@@ -183,11 +183,11 @@ jobs:
const { data: fileContent } = await github.rest.repos.getContent({
owner: repoOwner,
repo: repoName,
path: "frontend/editor/public/locales/en-GB/translation.toml",
path: "frontend/editor/public/locales/en-US/translation.toml",
ref: "main",
});
referenceFilePath = "main-branch-translation-en-GB.toml";
referenceFilePath = "main-branch-translation-en-US.toml";
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
fs.writeFileSync(referenceFilePath, content);
}
@@ -293,6 +293,6 @@ jobs:
run: |
echo "Cleaning up temporary files..."
rm -rf pr-branch
rm -f pr-branch-translation-en-GB.toml main-branch-translation-en-GB.toml changed_files.txt result.txt
rm -f pr-branch-translation-en-US.toml main-branch-translation-en-US.toml changed_files.txt result.txt
echo "Cleanup complete."
continue-on-error: true # Ensure cleanup runs even if previous steps fail
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.5.1
cache-disabled: true
# No `-PnoSpotless` here yet because the upstream cache layer matches the
+3 -3
View File
@@ -107,7 +107,7 @@ jobs:
- name: Build and push frontend image (Depot)
if: env.USE_DEPOT == 'true' && steps.check-frontend.outputs.exists == 'false'
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
@@ -136,7 +136,7 @@ jobs:
- name: Build and push backend image (Depot)
if: env.USE_DEPOT == 'true' && steps.check-backend.outputs.exists == 'false'
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
@@ -188,7 +188,7 @@ jobs:
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-GB
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: "Stirling-PDF V2"
UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split"
UI_APPNAMENAVBAR: "V2 Deployment"
@@ -1,136 +0,0 @@
name: Docker Compose Cucumber tests (saas / PAYG)
# Self-contained CI job for the PAYG shadow-mode cucumber scenarios.
# Triggers only on PAYG-relevant paths so we don't add CI minutes to every PR
# that doesn't touch the saas flavour.
#
# Companion to `docker-compose-tests.yml` (which runs against the
# proprietary-flavour stack and skips features/payg via behave.ini's
# exclude_re). Kept as a separate workflow so the saas matrix can fail and
# succeed independently without touching the main cucumber harness.
on:
pull_request:
paths:
- "app/saas/**"
- "testing/cucumber/features/payg/**"
- "testing/cucumber/features/steps/payg_step_definitions.py"
- "testing/cucumber/requirements.txt"
- "testing/compose/docker-compose-saas.yml"
- "testing/compose/payg/**"
- "testing/test-payg.sh"
- ".github/workflows/docker-compose-tests-saas.yml"
push:
branches: [main]
paths:
- "app/saas/**"
- "testing/cucumber/features/payg/**"
- "testing/cucumber/features/steps/payg_step_definitions.py"
- "testing/cucumber/requirements.txt"
- "testing/compose/docker-compose-saas.yml"
- "testing/compose/payg/**"
- "testing/test-payg.sh"
- ".github/workflows/docker-compose-tests-saas.yml"
permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
docker-compose-tests-saas:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
permissions:
actions: write
contents: read
checks: write
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-saas-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
cache-disabled: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Expose GitHub runtime for Buildx cache
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
# No "Install Docker Compose" step: Ubuntu runners ship with `docker compose`
# v2 (built into the Docker CLI). test-payg.sh uses the v2 form throughout
# (`docker compose …`, no hyphen), so the legacy v1 `docker-compose` binary
# isn't needed. Avoids a `curl | sudo install` without checksum verification
# (Aikido flagged this when copy-pasted from docker-compose-tests.yml).
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: ./testing/cucumber/requirements.txt
- name: Pip requirements
run: |
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
- name: Run PAYG Cucumber Tests
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
run: |
chmod +x ./testing/test-payg.sh
./testing/test-payg.sh
- name: Dump saas container logs on failure
if: failure()
run: |
docker compose -f testing/compose/docker-compose-saas.yml logs --tail 500 stirling-pdf-saas || true
docker compose -f testing/compose/docker-compose-saas.yml logs --tail 200 postgres-saas || true
- name: Upload PAYG Cucumber Report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: payg-cucumber-report
path: testing/cucumber/report-payg.html
retention-days: 7
if-no-files-found: warn
- name: PAYG Cucumber Test Report
if: always()
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: PAYG Cucumber Tests
path: testing/cucumber/junit-payg/*.xml
reporter: java-junit
fail-on-error: false
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.5.1
cache-disabled: true
- name: Set up Docker Buildx
+23 -3
View File
@@ -42,7 +42,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Install Playwright (chromium only)
run: task e2e:install -- chromium
- name: Build frontend (production bundle for vite preview)
@@ -188,10 +188,30 @@ jobs:
name: backend-log-live-${{ github.run_id }}
path: .test-state/playwright/backend.log
retention-days: 7
- name: Upload Playwright report
- name: List Playwright output locations (debug)
if: always()
run: |
echo "::group::Playwright output dirs"
# Playwright anchors its default outputDir + HTML report to the
# nearest package.json, which is frontend/ (frontend/editor has
# none), so artifacts land under frontend/, not frontend/editor/.
ls -la frontend/playwright-report 2>/dev/null \
|| echo "no playwright-report at frontend/"
ls -la frontend/test-results 2>/dev/null \
|| echo "no test-results at frontend/"
find . -name node_modules -prune -o -name 'trace.zip' -print 2>/dev/null || true
echo "::endgroup::"
- name: Upload Playwright report + traces
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-live-${{ github.run_id }}
path: frontend/editor/playwright-report/
# test-results/ holds the per-test trace.zip (with browser console
# logs) + screenshots/video; playwright-report/ is the HTML report.
# Both live under frontend/ (Playwright anchors them to the nearest
# package.json, which is frontend/; frontend/editor has none).
path: |
frontend/playwright-report/
frontend/test-results/
retention-days: 7
if-no-files-found: warn
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Install Playwright (chromium only)
run: task e2e:install -- chromium
- name: Build frontend (production bundle for vite preview)
@@ -97,7 +97,7 @@ jobs:
run: npm ci --ignore-scripts --audit=false --fund=false
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Generate frontend license report (internal PR)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
env:
@@ -349,10 +349,10 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.5.1
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Check licenses and generate report
id: license-check
run: task backend:licenses:generate || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Quality-check frontend
id: frontend-check
run: task frontend:check:all
+6 -6
View File
@@ -73,10 +73,10 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.5.1
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Get version number
id: versionNumber
run: |
@@ -148,7 +148,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.5.1
- name: Setup Node.js
if: matrix.variant.build_frontend == true
@@ -159,7 +159,7 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Build JAR
run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
@@ -252,10 +252,10 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.5.1
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
# Build the universal JRE before desktop:prepare so the jlink:runtime
# task short-circuits on its `test -d runtime/jre` status check.
+13 -1
View File
@@ -37,7 +37,7 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Install all Playwright browsers
run: task e2e:install
@@ -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
@@ -75,7 +75,7 @@ jobs:
- name: Generate tags for base image
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-base
+6 -6
View File
@@ -78,14 +78,14 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.5.1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
@@ -129,7 +129,7 @@ jobs:
- name: Generate tags for latest
id: meta
if: env.RUN_MAIN_APP == 'true'
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
@@ -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
@@ -178,7 +178,7 @@ jobs:
- name: Generate tags for latest-fat
id: meta-fat
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
images: |
@@ -222,7 +222,7 @@ jobs:
- name: Generate tags for ultra-lite
id: meta-lite
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
images: |
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
egress-policy: audit
- name: 30 days stale issues
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30
+2 -2
View File
@@ -48,7 +48,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.5.1
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
@@ -63,7 +63,7 @@ jobs:
SWAGGERHUB_USER: "Frooodle"
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
+13 -5
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-GB/translation.toml" --branch main
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: |
@@ -100,7 +108,7 @@ jobs:
This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made:
#### **1. Synchronization of Translation Files**
- Updated translation files (`frontend/editor/public/locales/*/translation.toml`) to reflect changes in the reference file `en-GB/translation.toml`.
- Updated translation files (`frontend/editor/public/locales/*/translation.toml`) to reflect changes in the reference file `en-US/translation.toml`.
- Ensured consistency and synchronization across all supported language files.
- Highlighted any missing or incomplete translations.
- **Format**: TOML
+16 -2
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
@@ -136,10 +150,10 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.5.1
- name: Setup Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Build universal macOS JRE
if: matrix.platform == 'macos-15'
+4 -4
View File
@@ -106,11 +106,11 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.5.1
cache-disabled: true
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Build application
run: task backend:build
env:
@@ -157,7 +157,7 @@ jobs:
- name: Build ${{ matrix.docker-rev }} (Depot)
if: env.USE_DEPOT == 'true'
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
@@ -230,7 +230,7 @@ jobs:
- name: Build docker/unoserver/Dockerfile (Depot)
if: env.USE_DEPOT == 'true'
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
+3 -3
View File
@@ -51,7 +51,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.5.1
- name: Build with Gradle
run: ./gradlew build
@@ -83,7 +83,7 @@ jobs:
- name: Build and push test image (Depot)
if: env.USE_DEPOT == 'true'
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
@@ -129,7 +129,7 @@ jobs:
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-GB
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: "Stirling-PDF Test"
UI_HOMEDESCRIPTION: "Test Deployment"
UI_APPNAMENAVBAR: "Test"
+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/
+17 -1
View File
@@ -1,5 +1,21 @@
# 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.
engine/.env:generic-api-key:41
# MCP test fixtures / harness - no real secrets:
# - test-only API key constant in an integration test
# - JDBC URL + throwaway Keycloak creds in the local test compose
# - placeholder / shell-variable Bearer headers in curl-based validation scripts
app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiKeyIntegrationTest.java:generic-api-key:40
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
+21 -2
View File
@@ -18,17 +18,30 @@ version: '3'
tasks:
dev:
desc: "Start backend dev server"
cmds:
- task: dev:proprietary
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"
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
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:
@@ -50,9 +63,15 @@ tasks:
PORT: '{{.PORT | default "8080"}}'
# 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: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
cmds:
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}"
platforms: [windows]
+9 -8
View File
@@ -127,14 +127,15 @@ tasks:
cmds:
- rm -rf runtime/jre
- mkdir -p runtime
- >-
jlink
--add-modules {{.JLINK_MODULES}}
--strip-debug
--compress=zip-6
--no-header-files
--no-man-pages
--output runtime/jre
- |
JLINK_COMPRESS="$(jlink --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
jlink \
--add-modules {{.JLINK_MODULES}} \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
# jlink emits its files mode 444 (read-only). Tauri's build-script
# resource copier preserves source permissions when staging
# `runtime/jre/**/*` into `target/<profile>/runtime/jre/...`, so the
+5
View File
@@ -20,6 +20,11 @@ tasks:
cmds:
- docker build -t stirling-pdf-ultra-lite -f {{.EMBEDDED_DIR}}/Dockerfile.ultra-lite .
build:backend:
desc: "Build backend-only Docker image (no embedded frontend)"
cmds:
- docker build -t stirling-pdf-backend -f docker/backend/Dockerfile .
build:frontend:
desc: "Build frontend-only Docker image"
cmds:
+52
View File
@@ -212,3 +212,55 @@ tasks:
desc: "Stop the SAML keycloak test environment"
cmds:
- docker compose -f testing/compose/docker-compose-keycloak-saml.yml down -v
mcp:up:
desc: "Start the MCP keycloak test environment (Stirling as OAuth resource server)"
summary: |
Brings up Keycloak (OAuth authorization server) + Stirling configured as an
MCP resource server, then you can exercise /mcp with real Keycloak tokens.
Set LICENSE_KEY=<KEY> to skip the interactive license prompt:
task e2e:mcp:up LICENSE_KEY=abc123
Pass extra flags via -- :
task e2e:mcp:up -- --validate --nobuild
ignore_error: true
cmds:
- bash testing/compose/start-mcp-test.sh {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
mcp:manual:
desc: "Start the MCP keycloak test env in manual mode (prints URLs + a live token for your client)"
summary: |
Brings the stack up and prints copy-paste URLs/commands plus a freshly minted
access token so you can drive your own MCP client (Inspector, curl, ...).
task e2e:mcp:manual LICENSE_KEY=<your-license-key>
Add --nobuild if the images are already built:
task e2e:mcp:manual LICENSE_KEY=<your-license-key> -- --nobuild
ignore_error: true
cmds:
- bash testing/compose/start-mcp-test.sh --manual {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
mcp:apikey:
desc: "Start the MCP test env in API-KEY manual mode (no OAuth/IdP): mints a key + prints client settings"
summary: |
Brings Stirling up in apikey auth mode and prints copy-paste client settings with a freshly
minted X-API-KEY - ideal for clients whose OAuth layer can't reach localhost.
task e2e:mcp:apikey LICENSE_KEY=<your-license-key>
Add --nobuild if images are already built:
task e2e:mcp:apikey LICENSE_KEY=<your-license-key> -- --nobuild
ignore_error: true
cmds:
- bash testing/compose/start-mcp-test.sh --apikey {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
mcp:validate:
desc: "Validate the running MCP keycloak test environment end-to-end (oauth mode + real MCP SDK client)"
cmds:
- bash testing/compose/validate-mcp-test.sh
mcp:validate-apikey:
desc: "Validate the MCP server in API-KEY auth mode (mints a key + real MCP SDK client), then restore oauth"
cmds:
- bash testing/compose/validate-mcp-apikey.sh
mcp:down:
desc: "Stop the MCP keycloak test environment"
cmds:
- docker compose -f testing/compose/docker-compose-keycloak-mcp.yml down -v
+1 -1
View File
@@ -33,7 +33,7 @@ tasks:
env:
PYTHONUNBUFFERED: "1"
cmds:
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}}
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --workers "${STIRLING_ENGINE_WORKERS:-4}"
dev:
desc: "Start engine dev server with hot reload"
+95 -6
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"
@@ -262,10 +323,17 @@ tasks:
cmds:
- npx tsc --noEmit --project editor/src/desktop/tsconfig.json
typecheck:cloud:
desc: "Typecheck cloud shared layer (standalone)"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/cloud/tsconfig.json
typecheck:scripts:
desc: "Typecheck scripts"
deps: [prepare]
cmds:
- npx tsc --noEmit --project scripts/tsconfig.json
- npx tsc --noEmit --project editor/scripts/tsconfig.json
typecheck:prototypes:
@@ -293,6 +361,7 @@ tasks:
- task: typecheck:proprietary
- task: typecheck:saas
- task: typecheck:desktop
- task: typecheck:cloud
- task: typecheck:scripts
- task: typecheck:prototypes
- task: typecheck:portal
@@ -310,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
@@ -327,19 +404,19 @@ tasks:
test:
desc: "Run tests"
deps: [install]
deps: [prepare]
cmds:
- npx vitest run --root editor
test:watch:
desc: "Run tests in watch mode"
deps: [install]
deps: [prepare]
cmds:
- npx vitest --watch --root editor
test:coverage:
desc: "Run tests with coverage (one-shot; CI-friendly)."
deps: [install]
deps: [prepare]
cmds:
# `vitest run` makes this CI-safe (the bare `vitest` form enters watch
# mode). Explicit reporter list because v8 + json-summary is what the
@@ -367,3 +444,15 @@ tasks:
deps: [install]
cmds:
- node editor/scripts/generate-licenses.js
# ============================================================
# Clean
# ============================================================
clean:
desc: "Clean build artifacts and caches"
cmds:
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist, dist-portal
platforms: [windows]
- cmd: rm -rf node_modules/.vite editor/dist dist dist-portal
platforms: [linux, darwin]
+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
+3 -3
View File
@@ -200,9 +200,9 @@ const [ToolName] = (props: BaseToolProps) => {
```
## 5. Add Translations
Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately.
Update translation files. **Important: Only update `en-US` files** - other languages are handled separately.
**File to update:** `frontend/editor/public/locales/en-GB/translation.toml`
**File to update:** `frontend/editor/public/locales/en-US/translation.toml`
**Required Translation Keys**:
```toml
@@ -251,7 +251,7 @@ Update translation files. **Important: Only update `en-GB` files** - other langu
```
**Translation Notes:**
- **Only update `en-GB/translation.toml`** - other locale files are managed separately
- **Only update `en-US/translation.toml`** - other locale files are managed separately
- Use descriptive keys that match your component's `t()` calls
- Include tooltip translations if you created tooltip hooks
- Add `options.*` keys if your tool has settings with descriptions
+27 -3
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
@@ -169,7 +169,31 @@ import { useFileContext } from "@proprietary/contexts/FileContext";
- Building layer-specific override that wraps a lower layer's component
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/saas/desktop/cloud) and handles the fallback cascade — see "Frontend `cloud/` Layer" below for the full per-flavor order.
#### Frontend `cloud/` Layer
`@app/*` resolves through a per-flavor cascade — first existing file wins (shadow/override):
- **core** → core
- **proprietary** → proprietary → core
- **saas** → saas → cloud → proprietary → core
- **desktop** → desktop → cloud → proprietary → core
- **cloud** → cloud → proprietary → core
What goes where:
- **core** — OSS base.
- **proprietary** — licensed / offline features.
- **cloud** — the SHARED hosted/SaaS experience used by BOTH saas + desktop: PAYG, wallet, plan, billing, usage meters, cloud config/team/onboarding.
- **saas** — web-only: Supabase web auth, AuthCallback, avatar canvas, `window.location`.
- **desktop** — Tauri-only: keyring authService, tauriHttpClient, native files/windows, backend routing.
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (enforced by ESLint). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
**Cloud feature flags on desktop.** The local `AppConfigContext` reads `/api/v1/config/app-config` from the LOCAL bundled backend, so cloud-only flags (`aiEngineEnabled`, `premiumEnabled`, …) are never seen on desktop. To read the cloud's view, use `useSaasAppConfig()` (`desktop/hooks/useSaasAppConfig.ts`, backed by the general `saasAppConfigService` — SaaS-mode-only, public endpoint, native HTTP, 5-min cache). It returns `null` outside SaaS mode, so cloud features stay off in local/self-hosted and the server keeps the on/off switch (no desktop release needed to flip a flag). Gate a feature behind a per-platform seam — e.g. `useAiEngineEnabled()` (core reads `useAppConfig()`, desktop reads `useSaasAppConfig()`) — rather than hardcoding the flag on.
#### Component Override Pattern (Stub/Shadow)
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
@@ -426,7 +450,7 @@ The frontend is organized with a clear separation of concerns:
## Translation Rules
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
- **CRITICAL**: Always update translations in `en-US` only - all other languages (including `en-GB`) are handled separately
- Translation files are located in `frontend/editor/public/locales/`
## Important Notes
+12 -1
View File
@@ -52,6 +52,17 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
- Rust and Cargo (required for Tauri desktop app development)
- Tauri CLI (install with `cargo install tauri-cli`)
### Optional System Dependencies
These are not required to run the app but enable specific features. The app detects them at startup and disables the relevant features if they are missing.
| Dependency | Feature | Install |
|---|---|---|
| LibreOffice | File-to-PDF conversions | `brew install libreoffice` / `apt install libreoffice` |
| Tesseract | OCR | `brew install tesseract` / `apt install tesseract-ocr` |
| WeasyPrint | AI document creation | `brew install weasyprint` / `apt install weasyprint` |
| qpdf | PDF optimisation | `brew install qpdf` / `apt install qpdf` |
### Setup Steps
1. Clone the repository:
@@ -576,7 +587,7 @@ When adding a new feature or modifying existing ones in Stirling-PDF, you'll nee
Find the existing `messages.properties` files in the `stirling-pdf/src/main/resources` directory. You'll see files like:
- `messages.properties` (default, usually English)
- `messages_en_GB.properties`
- `messages_en_US.properties`
- `messages_fr_FR.properties`
- `messages_de_DE.properties`
- etc.
+2
View File
@@ -16,6 +16,8 @@ if that directory exists, is licensed under the license defined in "frontend/edi
if that directory exists, is licensed under the license defined in "frontend/editor/src/desktop/LICENSE".
* All content that resides under the "frontend/editor/src/saas/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/saas/LICENSE".
* All content that resides under the "frontend/editor/src/cloud/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE".
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
* All content that resides under the "frontend/portal/" directory of this repository,
+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).
+118 -8
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,26 +78,96 @@ tasks:
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
dev:saas:
desc: "Start SaaS backend + frontend concurrently on free ports"
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}}'
FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev:saas
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
- task: frontend:dev:saas
SECURITY_ENABLELOGIN: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.FRONTEND_PORT}}'
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:
- task: dev:_all
vars: { FRONTEND: saas, BACKEND: saas }
dev:all:
desc: "Start backend + frontend + engine concurrently on free ports"
cmds:
- task: dev:_all
dev:_all:
internal: true
vars:
FRONTEND: '{{.FRONTEND | default "proprietary"}}'
BACKEND: '{{.BACKEND | default "proprietary"}}'
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
@@ -87,11 +177,12 @@ tasks:
- task: engine:dev
vars:
PORT: '{{.ENGINE_PORT}}'
- task: backend:dev
- task: 'backend:dev:{{.BACKEND}}'
vars:
PORT: '{{.BACKEND_PORT}}'
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
- task: frontend:dev
AIENGINE_ENABLED: "true"
- task: 'frontend:dev:{{.FRONTEND}}'
vars:
PORT: '{{.FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
@@ -107,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
# ============================================================
@@ -175,4 +283,6 @@ tasks:
desc: "Clean all build artifacts"
cmds:
- task: backend:clean
- task: frontend:clean
- task: engine:clean
- task: pre-commit:clean
@@ -1,73 +0,0 @@
package stirling.software.SPDF.pdf.parser;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Chains table parsers in priority order: Tabula lattice → Tabula stream → {@link
* LineAlignmentTableParser}. The first parser returning a result above {@link
* #TABULA_CONFIDENCE_THRESHOLD} wins; results from different parsers are never mixed on one page.
*/
@Service
@Primary
@RequiredArgsConstructor
@Slf4j
public class CompositeTableParser implements TableParser {
/** Min Tabula confidence to accept results; below this LineAlignment is tried instead. */
static final float TABULA_CONFIDENCE_THRESHOLD = 0.5f;
private final TabulaTableParser tabulaParser;
private final LineAlignmentTableParser lineAlignmentParser;
@Override
public List<TableFragment> parse(PDDocument document, RawPage rawPage) throws IOException {
// Step 1: Tabula lattice mode (ruled/bordered tables).
List<TableFragment> latticeResults = filterConfident(tabulaParser.parse(document, rawPage));
if (!latticeResults.isEmpty()) {
log.debug(
"Page {}: using Tabula lattice ({} table(s))",
rawPage.pageNumber(),
latticeResults.size());
return latticeResults;
}
// Step 2: Tabula stream mode (borderless/whitespace-delimited tables).
// parseStream is not on the TableParser interface — this intentionally couples to the
// concrete TabulaTableParser since stream mode is a Tabula-specific concept.
List<TableFragment> streamResults =
filterConfident(tabulaParser.parseStream(document, rawPage));
if (!streamResults.isEmpty()) {
log.debug(
"Page {}: using Tabula stream ({} table(s))",
rawPage.pageNumber(),
streamResults.size());
return streamResults;
}
// Step 3: Geometry-based line-alignment fallback.
List<TableFragment> lineResults = lineAlignmentParser.parse(document, rawPage);
if (!lineResults.isEmpty()) {
log.debug(
"Page {}: using LineAlignment ({} table(s))",
rawPage.pageNumber(),
lineResults.size());
return lineResults;
}
return List.of();
}
private List<TableFragment> filterConfident(List<TableFragment> tables) {
return tables.stream().filter(t -> t.confidence() >= TABULA_CONFIDENCE_THRESHOLD).toList();
}
}
@@ -1,528 +0,0 @@
package stirling.software.SPDF.pdf.parser;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Fallback {@link TableParser} for borderless financial tables using text geometry.
*
* <p>Identifies "anchor lines" (≥2 numeric tokens), builds a column grid from their right-edge
* positions, groups vertically proximate anchor lines into table candidates, then scores each group
* on column consistency and anchor density (confidence ceiling 0.85).
*/
@Service
@Slf4j
public class LineAlignmentTableParser implements TableParser {
/** Width in points of each column position bucket. */
static final float COLUMN_BUCKET_PT = 5f;
/** Tolerance in buckets when matching a token's right-edge to a confirmed column position. */
private static final int COLUMN_MATCH_BUCKETS = 2;
/** Maximum gap (as a multiple of modal line spacing) before splitting a group. */
private static final float MAX_GAP_FACTOR = 2.5f;
/** Minimum anchor rows (numeric-heavy) to form a valid table. */
static final int MIN_TABLE_ROWS = 3;
/** Minimum confirmed column positions to form a valid table. */
static final int MIN_COLUMNS = 2;
/**
* Min fraction of anchor lines a column must appear on to be confirmed (permissive for N/A
* rows).
*/
private static final double COLUMN_MIN_FREQUENCY = 0.40;
/**
* Matches financial numeric tokens: integers, decimals, parenthetical negatives, currency,
* percent, nil dashes.
*/
private static final Pattern NUMERIC =
Pattern.compile("^[\\(\\-\\$£€¥]?\\d[\\d,\\.]*[\\)%]?$|^[-–—]$");
/**
* Lines within this y-distance are merged into one row (restores rows split by LineBuilder's
* column-gap logic).
*/
static final float ROW_MERGE_TOLERANCE_PT = 2f;
// ── public API ───────────────────────────────────────────────────────────────────────────────
@Override
public List<TableFragment> parse(PDDocument document, RawPage rawPage) throws IOException {
List<RawLine> lines = rawPage.lines();
if (lines.size() < MIN_TABLE_ROWS) return List.of();
float modalSpacing = computeModalSpacing(lines);
List<TokenizedLine> tokenized =
mergeCoincidentLines(lines.stream().map(this::tokenize).toList());
List<TokenizedLine> anchors = tokenized.stream().filter(TokenizedLine::isAnchor).toList();
if (anchors.size() < MIN_TABLE_ROWS) return List.of();
List<Float> columnGrid = buildColumnGrid(anchors);
if (columnGrid.size() < MIN_COLUMNS) {
log.debug(
"Page {}: LineAlignment — fewer than {} confirmed columns, skipping",
rawPage.pageNumber(),
MIN_COLUMNS);
return List.of();
}
List<List<TokenizedLine>> groups = groupRows(tokenized, columnGrid, modalSpacing);
List<TableFragment> results = new ArrayList<>();
for (int i = 0; i < groups.size(); i++) {
buildFragment(groups.get(i), columnGrid, rawPage.pageNumber(), i)
.ifPresent(results::add);
}
log.debug(
"Page {}: LineAlignment detected {} table(s) ({} anchor lines, {} columns)",
rawPage.pageNumber(),
results.size(),
anchors.size(),
columnGrid.size());
return results;
}
// ── coincident-line merging ──────────────────────────────────────────────────────────────────
/**
* Merges tokenised lines sharing the same y-position into one row, rejoining label/value halves
* split by LineBuilder.
*/
List<TokenizedLine> mergeCoincidentLines(List<TokenizedLine> tokenized) {
if (tokenized.size() < 2) return tokenized;
List<TokenizedLine> result = new ArrayList<>();
int i = 0;
while (i < tokenized.size()) {
float baseY = tokenized.get(i).line().bounds().y();
int j = i + 1;
while (j < tokenized.size()
&& Math.abs(tokenized.get(j).line().bounds().y() - baseY)
<= ROW_MERGE_TOLERANCE_PT) {
j++;
}
if (j == i + 1) {
result.add(tokenized.get(i));
} else {
result.add(mergeGroup(tokenized.subList(i, j)));
}
i = j;
}
return result;
}
private TokenizedLine mergeGroup(List<TokenizedLine> group) {
List<TextFragment> mergedFragments =
group.stream()
.flatMap(tl -> tl.line().fragments().stream())
.sorted(Comparator.comparingDouble(f -> f.bounds().x()))
.toList();
Bounds mergedBounds =
group.stream()
.map(tl -> tl.line().bounds())
.reduce(Bounds::merge)
.orElse(group.get(0).line().bounds());
RawLine mergedLine =
new RawLine(
group.get(0).line().lineId(),
mergedFragments,
mergedBounds,
group.get(0).line().pageNumber());
return tokenize(mergedLine);
}
// ── tokenisation ─────────────────────────────────────────────────────────────────────────────
/**
* Splits fragments into word-level tokens; x-positions are estimated linearly within each
* fragment.
*/
TokenizedLine tokenize(RawLine line) {
List<LineToken> tokens = new ArrayList<>();
for (TextFragment frag : line.fragments()) {
tokens.addAll(tokensFromFragment(frag));
}
List<LineToken> numeric = tokens.stream().filter(LineToken::numeric).toList();
return new TokenizedLine(line, tokens, numeric);
}
private List<LineToken> tokensFromFragment(TextFragment frag) {
String raw = frag.text();
if (raw == null || raw.isBlank()) return List.of();
float fragX = frag.bounds().x();
float fragWidth = frag.bounds().width();
int rawLen = raw.length();
List<LineToken> result = new ArrayList<>();
int offset = 0;
for (String part : raw.split("\\s+")) {
if (part.isEmpty()) {
offset++;
continue;
}
int idx = raw.indexOf(part, offset);
if (idx < 0) idx = offset;
float tokenX = rawLen > 0 ? fragX + ((float) idx / rawLen) * fragWidth : fragX;
float tokenRight =
rawLen > 0
? fragX + ((float) (idx + part.length()) / rawLen) * fragWidth
: fragX + fragWidth;
result.add(new LineToken(part, tokenX, tokenRight, NUMERIC.matcher(part).matches()));
offset = idx + part.length();
}
return result;
}
// ── column grid ──────────────────────────────────────────────────────────────────────────────
/**
* Returns confirmed column right-edge positions — those appearing on ≥ {@value
* #COLUMN_MIN_FREQUENCY} × N anchor lines.
*/
private List<Float> buildColumnGrid(List<TokenizedLine> anchors) {
// bucket → set of line indices that contributed a numeric token to that bucket
Map<Integer, List<Integer>> bucketLines = new HashMap<>();
for (int i = 0; i < anchors.size(); i++) {
for (LineToken t : anchors.get(i).numeric()) {
int bucket = bucket(t.right());
bucketLines.computeIfAbsent(bucket, k -> new ArrayList<>()).add(i);
}
}
int minHits =
Math.max(MIN_TABLE_ROWS, (int) Math.ceil(anchors.size() * COLUMN_MIN_FREQUENCY));
// Confirmed buckets → average right-edge for that bucket
TreeMap<Integer, Float> confirmed = new TreeMap<>();
for (Map.Entry<Integer, List<Integer>> entry : bucketLines.entrySet()) {
// Count distinct lines
long distinctLines = entry.getValue().stream().distinct().count();
if (distinctLines >= minHits) {
double avg =
entry.getValue().stream()
.distinct() // weight each line equally regardless of token count
.mapToDouble(
lineIdx ->
avgRightEdgeForBucket(
anchors, lineIdx, entry.getKey()))
.average()
.orElse(entry.getKey() * (double) COLUMN_BUCKET_PT);
confirmed.put(entry.getKey(), (float) avg);
}
}
return new ArrayList<>(confirmed.values()); // already sorted by bucket (left to right)
}
/**
* Returns the average right-edge position of tokens in {@code line} whose bucket matches {@code
* targetBucket}, falling back to the bucket's nominal centre when no tokens match.
*/
private double avgRightEdgeForBucket(
List<TokenizedLine> anchors, int lineIdx, int targetBucket) {
return anchors.get(lineIdx).numeric().stream()
.filter(t -> bucket(t.right()) == targetBucket)
.mapToDouble(LineToken::right)
.average()
.orElse(targetBucket * (double) COLUMN_BUCKET_PT);
}
// ── grouping ─────────────────────────────────────────────────────────────────────────────────
/**
* Groups anchor lines into table candidates, including adjacent label rows; a gap &gt;
* MAX_GAP_FACTOR × modal spacing splits groups.
*/
private List<List<TokenizedLine>> groupRows(
List<TokenizedLine> all, List<Float> columnGrid, float modalSpacing) {
float maxGap = modalSpacing > 0 ? modalSpacing * MAX_GAP_FACTOR : 30f;
List<List<TokenizedLine>> groups = new ArrayList<>();
List<TokenizedLine> current = new ArrayList<>();
for (int i = 0; i < all.size(); i++) {
TokenizedLine tl = all.get(i);
boolean fits = tl.isAnchor() && matchesGrid(tl, columnGrid);
if (current.isEmpty()) {
if (fits) current.add(tl);
continue;
}
float gap =
tl.line().bounds().y()
- current.get(current.size() - 1).line().bounds().bottom();
if (gap > maxGap) {
groups.add(current);
current = new ArrayList<>();
if (fits) current.add(tl);
continue;
}
if (fits) {
current.add(tl);
} else if (!tl.line().text().isBlank()) {
// Include non-anchor lines (labels) only if they have text and are within
// proximity.
current.add(tl);
}
}
if (!current.isEmpty()) groups.add(current);
return groups.stream().filter(g -> hasEnoughAnchorRows(g, columnGrid)).toList();
}
private boolean hasEnoughAnchorRows(List<TokenizedLine> group, List<Float> columnGrid) {
return group.stream().filter(r -> r.isAnchor() && matchesGrid(r, columnGrid)).count()
>= MIN_TABLE_ROWS;
}
/** A line "matches" the grid when ≥ 60 % of its numeric tokens land in confirmed columns. */
private boolean matchesGrid(TokenizedLine tl, List<Float> columnGrid) {
if (tl.numeric().isEmpty()) return false;
long matches =
tl.numeric().stream()
.filter(t -> nearestColumnIndex(t.right(), columnGrid) >= 0)
.count();
return (double) matches / tl.numeric().size() >= 0.60;
}
private boolean hasInconsistentColumnMatch(TokenizedLine tl, List<Float> columnGrid) {
if (tl.numeric().isEmpty()) return false;
long hits =
tl.numeric().stream()
.filter(t -> nearestColumnIndex(t.right(), columnGrid) >= 0)
.count();
return (double) hits / tl.numeric().size() < 0.60;
}
// ── fragment assembly ────────────────────────────────────────────────────────────────────────
private Optional<TableFragment> buildFragment(
List<TokenizedLine> group, List<Float> columnGrid, int pageNumber, int tableIndex) {
long anchorCount =
group.stream().filter(r -> r.isAnchor() && matchesGrid(r, columnGrid)).count();
if (anchorCount < MIN_TABLE_ROWS) return Optional.empty();
List<String> warnings = new ArrayList<>();
List<List<String>> rawRows = new ArrayList<>();
List<TableRow> rows = new ArrayList<>();
for (int rowIdx = 0; rowIdx < group.size(); rowIdx++) {
TokenizedLine tl = group.get(rowIdx);
List<String> rawRow = buildRawRow(tl, columnGrid);
rawRows.add(Collections.unmodifiableList(rawRow));
rows.add(buildTableRow(rowIdx, tl, rawRow, columnGrid));
}
// Column count = 1 label column + confirmed numeric columns
int colCount = columnGrid.size() + 1;
Bounds bounds = computeGroupBounds(group);
float confidence = computeConfidence(group, columnGrid, warnings);
return Optional.of(
new TableFragment(
"tbl-la-p" + pageNumber + "-" + tableIndex,
pageNumber,
bounds,
List.of(),
Collections.unmodifiableList(rows),
Collections.unmodifiableList(rawRows),
colCount,
confidence,
Collections.unmodifiableList(warnings),
null));
}
/**
* Builds a raw row as a list of strings: index 0 = label text, indices 1..N = column values.
*/
private List<String> buildRawRow(TokenizedLine tl, List<Float> columnGrid) {
String[] cells = new String[columnGrid.size() + 1];
Arrays.fill(cells, "");
// Separate label tokens (those not landing in any confirmed column) from column tokens.
List<String> labelParts = new ArrayList<>();
for (LineToken token : tl.all()) {
int col = nearestColumnIndex(token.right(), columnGrid);
if (col >= 0 && token.numeric()) {
int cellIdx = col + 1;
cells[cellIdx] =
cells[cellIdx].isEmpty()
? token.text()
: cells[cellIdx] + " " + token.text();
} else {
labelParts.add(token.text());
}
}
cells[0] = String.join(" ", labelParts).trim();
return Arrays.asList(cells);
}
private TableRow buildTableRow(
int rowIdx, TokenizedLine tl, List<String> rawRow, List<Float> columnGrid) {
List<TableCell> cells = new ArrayList<>(rawRow.size());
// Label cell: use the line's full bounds as an approximation.
cells.add(TableCell.of(0, rawRow.get(0), tl.line().bounds()));
for (int col = 0; col < columnGrid.size(); col++) {
String text = col + 1 < rawRow.size() ? rawRow.get(col + 1) : "";
float right = columnGrid.get(col);
float left = col > 0 ? columnGrid.get(col - 1) : right - 50f;
Bounds cellBounds =
new Bounds(
left,
tl.line().bounds().y(),
right - left,
tl.line().bounds().height());
cells.add(TableCell.of(col + 1, text, cellBounds));
}
return new TableRow(rowIdx, Collections.unmodifiableList(cells));
}
// ── confidence scoring ───────────────────────────────────────────────────────────────────────
/**
* Heuristic score in [0.0, 0.85] (ceiling keeps results below Tabula lattice which starts at
* 1.0). Base 0.70; +0.05/col beyond 2 (max +0.10); +0.05 at ≥5 anchors, +0.05 at ≥8; 0.15 if
* &gt;30 % of anchors have inconsistent columns; 0.10 if non-anchors outnumber anchors.
*/
private float computeConfidence(
List<TokenizedLine> group, List<Float> columnGrid, List<String> warnings) {
float score = 0.70f;
long anchorCount =
group.stream().filter(r -> r.isAnchor() && matchesGrid(r, columnGrid)).count();
long totalRows = group.size();
// More columns
int extraCols = Math.min(columnGrid.size() - MIN_COLUMNS, 2);
score += extraCols * 0.05f;
// More anchor rows
if (anchorCount >= 5) score += 0.05f;
if (anchorCount >= 8) score += 0.05f;
// Inconsistent column matching
long inconsistent =
group.stream()
.filter(TokenizedLine::isAnchor)
.filter(tl -> hasInconsistentColumnMatch(tl, columnGrid))
.count();
if (inconsistent > anchorCount * 0.30) {
score -= 0.15f;
warnings.add(
"Column match inconsistent on "
+ inconsistent
+ "/"
+ anchorCount
+ " anchor rows");
}
// Label-heavy
long nonAnchor = totalRows - anchorCount;
if (nonAnchor > anchorCount) {
score -= 0.10f;
warnings.add(
"Non-anchor rows ("
+ nonAnchor
+ ") outnumber anchor rows ("
+ anchorCount
+ ")");
}
return Math.max(0f, Math.min(0.85f, score));
}
// ── utility ──────────────────────────────────────────────────────────────────────────────────
/**
* Returns the grid index nearest to {@code rightEdge}, or -1 if none is within {@value
* #COLUMN_MATCH_BUCKETS} buckets.
*/
private int nearestColumnIndex(float rightEdge, List<Float> grid) {
int nearest = -1;
float minDist = COLUMN_MATCH_BUCKETS * COLUMN_BUCKET_PT + 1f;
for (int i = 0; i < grid.size(); i++) {
float dist = Math.abs(rightEdge - grid.get(i));
if (dist < minDist) {
minDist = dist;
nearest = i;
}
}
return nearest;
}
private Bounds computeGroupBounds(List<TokenizedLine> group) {
return group.stream()
.map(tl -> tl.line().bounds())
.reduce(Bounds::merge)
.orElse(new Bounds(0, 0, 0, 0));
}
/** Modal gap between consecutive line edges, used to calibrate the group-split threshold. */
private float computeModalSpacing(List<RawLine> lines) {
if (lines.size() < 2) return 0f;
Map<Float, Long> freq = new HashMap<>();
for (int i = 1; i < lines.size(); i++) {
float gap = lines.get(i).bounds().y() - lines.get(i - 1).bounds().bottom();
if (gap > 0) freq.merge(Math.round(gap / 2f) * 2f, 1L, Long::sum);
}
return freq.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse(0f);
}
private static int bucket(float x) {
return Math.round(x / COLUMN_BUCKET_PT);
}
// ── private data types ───────────────────────────────────────────────────────────────────────
/** A word-level token with an approximate right-edge x-position. */
record LineToken(String text, float x, float right, boolean numeric) {}
/** A {@link RawLine} with tokens pre-computed; an "anchor" has ≥ 2 numeric tokens. */
record TokenizedLine(RawLine line, List<LineToken> all, List<LineToken> numeric) {
boolean isAnchor() {
return numeric.size() >= 2;
}
}
}
@@ -1,139 +0,0 @@
package stirling.software.SPDF.pdf.parser;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Groups {@link TextFragment} objects into visual {@link RawLine}s using baseline proximity.
*
* <p>Fragments are on the same line when their baselines are within a font-size-derived tolerance.
* A new line starts whenever the horizontal gap exceeds an adaptive column-gap threshold ({@code
* max(effectiveWidth * COLUMN_GAP_RATIO, COLUMN_GAP_MIN_PT)}), splitting two-column text.
*/
@Service
@Slf4j
public class LineBuilder {
/** Baseline tolerance as a fraction of font size; 0.5 keeps mixed-size text on one line. */
private static final float BASELINE_TOLERANCE_FACTOR = 0.5f;
/** Absolute minimum tolerance so tiny font sizes don't collapse multi-line content. */
private static final float MIN_BASELINE_TOLERANCE = 2f;
/**
* Column-gap threshold as a fraction of page width; 0.10 clears tab stops but stays below
* two-column gutters.
*/
static final float COLUMN_GAP_RATIO = 0.10f;
/** Floor for the column-gap threshold so narrow pages don't over-split lines. */
static final float COLUMN_GAP_MIN_PT = 40f;
public List<RawLine> build(List<TextFragment> fragments, int pageNumber) {
if (fragments.isEmpty()) return List.of();
float effectiveWidth = inferEffectiveWidth(fragments);
float columnGapThreshold = Math.max(effectiveWidth * COLUMN_GAP_RATIO, COLUMN_GAP_MIN_PT);
log.debug(
"LineBuilder page {}: effectiveWidth={:.1f}pt, columnGapThreshold={:.1f}pt",
pageNumber,
effectiveWidth,
columnGapThreshold);
// Sort top-to-bottom first, then left-to-right within the same baseline band.
List<TextFragment> sorted =
fragments.stream()
.sorted(
Comparator.comparingDouble(TextFragment::baseline)
.thenComparingDouble(f -> f.bounds().x()))
.toList();
List<List<TextFragment>> groups = groupByBaseline(sorted, columnGapThreshold);
List<RawLine> lines = new ArrayList<>(groups.size());
for (int i = 0; i < groups.size(); i++) {
List<TextFragment> group =
groups.get(i).stream()
.sorted(Comparator.comparingDouble(f -> f.bounds().x()))
.toList();
Bounds lineBounds =
group.stream()
.map(TextFragment::bounds)
.reduce(Bounds::merge)
.orElse(new Bounds(0, 0, 0, 0));
lines.add(new RawLine("ln-p" + pageNumber + "-" + i, group, lineBounds, pageNumber));
}
return lines;
}
private List<List<TextFragment>> groupByBaseline(
List<TextFragment> sorted, float columnGapThreshold) {
List<List<TextFragment>> groups = new ArrayList<>();
List<TextFragment> current = new ArrayList<>();
float currentBaseline = Float.NaN;
for (TextFragment fragment : sorted) {
if (current.isEmpty()) {
current.add(fragment);
currentBaseline = fragment.baseline();
continue;
}
float maxFontSize =
Math.max(
fragment.fontSize(),
(float)
current.stream()
.mapToDouble(TextFragment::fontSize)
.max()
.orElse(0));
float tolerance =
Math.max(maxFontSize * BASELINE_TOLERANCE_FACTOR, MIN_BASELINE_TOLERANCE);
boolean sameBaseline = Math.abs(fragment.baseline() - currentBaseline) <= tolerance;
boolean columnGap = sameBaseline && hasColumnGap(fragment, current, columnGapThreshold);
if (sameBaseline && !columnGap) {
current.add(fragment);
// Anchor to the weighted mean baseline so long lines stay stable.
currentBaseline =
(currentBaseline * (current.size() - 1) + fragment.baseline())
/ current.size();
} else {
groups.add(current);
current = new ArrayList<>();
current.add(fragment);
currentBaseline = fragment.baseline();
}
}
if (!current.isEmpty()) groups.add(current);
return groups;
}
/**
* True when the gap from the rightmost fragment in {@code group} to {@code next} exceeds {@code
* threshold}.
*/
private static boolean hasColumnGap(
TextFragment next, List<TextFragment> group, float threshold) {
float lastRight = group.get(group.size() - 1).bounds().right();
return next.bounds().x() - lastRight > threshold;
}
/** Infers effective page width from the rightmost fragment right-edge plus a 10 % margin. */
private static float inferEffectiveWidth(List<TextFragment> fragments) {
double maxRight =
fragments.stream().mapToDouble(f -> f.bounds().right()).max().orElse(500.0);
return (float) maxRight * 1.10f;
}
}
@@ -1,79 +0,0 @@
package stirling.software.SPDF.pdf.parser;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Runs the per-page ingestion pipeline: {@link WordExtractingStripper} → {@link LineBuilder} →
* {@link TableParser}, producing a {@link PdfModels.ParsedPage} per page. The caller owns the
* {@link PDDocument} lifecycle.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class PdfIngester {
private final LineBuilder lineBuilder;
private final TableParser tableParser;
public List<ParsedPage> parse(PDDocument document) throws IOException {
return parse(document, document.getNumberOfPages());
}
public List<ParsedPage> parse(PDDocument document, int maxPages) throws IOException {
int pageCount = Math.min(document.getNumberOfPages(), maxPages);
List<ParsedPage> pages = new ArrayList<>(pageCount);
long fragmentsMs = 0;
long tablesMs = 0;
long t0 = System.currentTimeMillis();
for (int p = 1; p <= pageCount; p++) {
long ft = System.currentTimeMillis();
List<TextFragment> fragments = extractFragments(document, p);
fragmentsMs += System.currentTimeMillis() - ft;
PDPage page = document.getPage(p - 1);
PDRectangle mediaBox = page.getMediaBox();
List<RawLine> lines = lineBuilder.build(fragments, p);
RawPage rawPage = new RawPage(p, mediaBox.getWidth(), mediaBox.getHeight(), lines);
long tt = System.currentTimeMillis();
List<TableFragment> tables = tableParser.parse(document, rawPage);
tablesMs += System.currentTimeMillis() - tt;
log.debug(
"Page {}: {} fragments → {} lines, {} table(s)",
p,
fragments.size(),
lines.size(),
tables.size());
pages.add(new ParsedPage(p, mediaBox.getWidth(), mediaBox.getHeight(), tables, lines));
}
log.info(
"[timing] parse pages={} total={}ms fragments={}ms tables={}ms",
pageCount,
System.currentTimeMillis() - t0,
fragmentsMs,
tablesMs);
return pages;
}
private List<TextFragment> extractFragments(PDDocument document, int pageNumber)
throws IOException {
WordExtractingStripper stripper = new WordExtractingStripper(pageNumber);
stripper.getText(document);
return stripper.getFragments();
}
}
@@ -1,113 +0,0 @@
package stirling.software.SPDF.pdf.parser;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
/**
* Extends {@link PDFTextStripper} to capture per-fragment geometry and font metadata.
*
* <p>Overrides {@link #writeString} to split each content-stream string into word-level {@link
* TextFragment}s with bounding boxes, baseline, font name, and bold flag. Coordinates are in
* PDFTextStripper space: (0,0) top-left, Y increases downward, {@code getY()} is the baseline.
*/
class WordExtractingStripper extends PDFTextStripper {
private final int targetPage;
private final List<TextFragment> fragments = new ArrayList<>();
private int fragmentIndex = 0;
WordExtractingStripper(int pageNumber) throws IOException {
this.targetPage = pageNumber;
setStartPage(pageNumber);
setEndPage(pageNumber);
setSortByPosition(true);
}
@Override
protected void startPage(PDPage page) throws IOException {
super.startPage(page);
fragments.clear();
fragmentIndex = 0;
}
@Override
protected void writeString(String text, List<TextPosition> textPositions) throws IOException {
if (text == null || text.isBlank()) return;
// Fast path: no whitespace → emit one fragment (most financial PDFs have each
// number as its own string operation, so this is the common case).
if (text.indexOf(' ') < 0) {
emitFragment(text, textPositions);
return;
}
// Per-word splitting requires 1:1 text-char to TextPosition correspondence.
// Fall back to one fragment when sizes differ (ligatures, encoding edge cases).
if (textPositions.size() != text.length()) {
emitFragment(text, textPositions);
return;
}
// Emit one TextFragment per whitespace-delimited word with accurate per-word bounds.
int start = 0;
for (int i = 0; i <= text.length(); i++) {
if (i == text.length() || text.charAt(i) == ' ') {
if (start < i) {
emitFragment(text.substring(start, i), textPositions.subList(start, i));
}
start = i + 1;
}
}
}
private void emitFragment(String text, List<TextPosition> positions) {
if (positions.isEmpty()) return;
float minX = Float.MAX_VALUE;
float minY = Float.MAX_VALUE;
float maxRight = -Float.MAX_VALUE;
float maxBaseline = -Float.MAX_VALUE;
TextPosition first = null;
for (TextPosition tp : positions) {
if (tp == null) continue;
if (first == null) first = tp;
float x = tp.getX();
// getY() is the baseline; top of character = getY() - getHeight().
float top = tp.getY() - tp.getHeight();
float right = x + tp.getWidth();
float baseline = tp.getY();
minX = Math.min(minX, x);
minY = Math.min(minY, top);
maxRight = Math.max(maxRight, right);
maxBaseline = Math.max(maxBaseline, baseline);
}
if (first == null) return;
PDFont font = first.getFont();
String fontName = font != null ? font.getName() : "";
boolean bold = fontName != null && fontName.toLowerCase().contains("bold");
// getHeight() gives the rendered glyph height, which is the most reliable visual size.
float fontSize = first.getHeight();
Bounds bounds = new Bounds(minX, minY, maxRight - minX, maxBaseline - minY);
String id = "tf-p" + targetPage + "-" + fragmentIndex++;
fragments.add(new TextFragment(id, text, bounds, maxBaseline, fontSize, fontName, bold));
}
List<TextFragment> getFragments() {
return Collections.unmodifiableList(fragments);
}
}
@@ -11,23 +11,39 @@ public interface FileStore {
/** Stored file record. */
record Stored(String fileId, long size) {}
/** Store the given stream and return a generated file id and total bytes written. */
Stored store(InputStream in, String originalName) throws IOException;
/**
* Store the given stream and return a generated file id and total bytes written. {@code owner}
* may be null to indicate the file has no associated user (anonymous / desktop / async job with
* no propagated security context); a non-null value is persisted alongside the data so {@link
* #getOwner(String)} can return it later for authorization checks.
*/
Stored store(InputStream in, String originalName, String owner) throws IOException;
/** Store with no owner. Equivalent to {@link #store(InputStream, String, String)} with null. */
default Stored store(InputStream in, String originalName) throws IOException {
return store(in, originalName, null);
}
/**
* Store the file at {@code source} and return a generated file id and total bytes written.
*
* <p>Default implementation opens {@code source} as a stream and delegates to {@link
* #store(InputStream, String)}. Local-disk implementations should override to use a direct
* file-to-file copy ({@code Files.copy(source, dest)} can use {@code sendfile(2)} on Linux),
* which avoids the two-memory-copy hit of streaming a disk-backed upload through the JVM heap.
* #store(InputStream, String, String)}. Local-disk implementations should override to use a
* direct file-to-file copy ({@code Files.copy(source, dest)} can use {@code sendfile(2)} on
* Linux), which avoids the two-memory-copy hit of streaming a disk-backed upload through the
* JVM heap.
*/
default Stored store(Path source, String originalName) throws IOException {
default Stored store(Path source, String originalName, String owner) throws IOException {
try (InputStream in = Files.newInputStream(source)) {
return store(in, originalName);
return store(in, originalName, owner);
}
}
/** Store with no owner. Equivalent to {@link #store(Path, String, String)} with null. */
default Stored store(Path source, String originalName) throws IOException {
return store(source, originalName, null);
}
/** Open the stored file for streaming reads. Caller closes. */
InputStream retrieve(String fileId) throws IOException;
@@ -42,4 +58,12 @@ public interface FileStore {
/** Whether the file id exists in the store. */
boolean exists(String fileId);
/**
* Returns the owner identifier recorded at store time, or {@code null} if the file does not
* exist or was stored without an owner. Implementations must not throw when the file is missing
* or when the owner record is absent; they should return null so callers can treat "no owner"
* as a non-authoritative case.
*/
String getOwner(String fileId) throws IOException;
}
@@ -3,9 +3,12 @@ package stirling.software.common.cluster.inprocess;
import java.io.BufferedInputStream;
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.UUID;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Pattern;
import lombok.extern.slf4j.Slf4j;
@@ -15,33 +18,47 @@ import stirling.software.common.cluster.FileStore;
@Slf4j
public class LocalDiskFileStore implements FileStore {
private static final String OWNER_SUFFIX = ".owner";
// File ids are generated as random UUIDs; reject anything else so a tainted id can never reach
// Files.* APIs (defence in depth on top of the resolve() prefix check, and silences CodeQL's
// path-injection finding on the resolveOwner sidecar lookup).
private static final Pattern UUID_PATTERN =
Pattern.compile(
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$");
private final String baseDirPath;
// Fixed-size lock stripes so concurrent store/delete on the same (or colliding) fileId
// serialise the data-file + owner-sidecar pair as one critical section. Striped (not
// per-id) so the map never has to be cleaned up; collisions across unrelated ids are
// harmless contention.
private static final int LOCK_STRIPES = 64;
private final ReentrantLock[] stripes = new ReentrantLock[LOCK_STRIPES];
public LocalDiskFileStore(String baseDirPath) {
this.baseDirPath = baseDirPath;
for (int i = 0; i < LOCK_STRIPES; i++) {
stripes[i] = new ReentrantLock();
}
}
@Override
public Stored store(InputStream in, String originalName) throws IOException {
public Stored store(InputStream in, String originalName, String owner) throws IOException {
String fileId = UUID.randomUUID().toString();
Path filePath = resolve(fileId);
Files.createDirectories(filePath.getParent());
ReentrantLock lock = acquire(fileId);
boolean success = false;
try {
long size = Files.copy(in, filePath);
writeOwner(fileId, owner);
success = true;
return new Stored(fileId, size);
} finally {
if (!success) {
try {
Files.deleteIfExists(filePath);
} catch (IOException cleanupEx) {
log.warn(
"Failed to clean up partial file {} after store failure",
filePath,
cleanupEx);
}
cleanupAfterFailedStore(fileId, filePath);
}
release(fileId, lock);
}
}
@@ -52,27 +69,44 @@ public class LocalDiskFileStore implements FileStore {
* the source size before copying so the post-copy stat is unnecessary.
*/
@Override
public Stored store(Path source, String originalName) throws IOException {
public Stored store(Path source, String originalName, String owner) throws IOException {
String fileId = UUID.randomUUID().toString();
Path filePath = resolve(fileId);
Files.createDirectories(filePath.getParent());
long size = Files.size(source);
ReentrantLock lock = acquire(fileId);
boolean success = false;
try {
Files.copy(source, filePath);
writeOwner(fileId, owner);
success = true;
return new Stored(fileId, size);
} finally {
if (!success) {
try {
Files.deleteIfExists(filePath);
} catch (IOException cleanupEx) {
log.warn(
"Failed to clean up partial file {} after store failure",
filePath,
cleanupEx);
}
cleanupAfterFailedStore(fileId, filePath);
}
release(fileId, lock);
}
}
private void writeOwner(String fileId, String owner) throws IOException {
if (owner == null || owner.isBlank()) {
return;
}
Path ownerPath = resolveOwner(fileId);
Files.write(ownerPath, owner.getBytes(StandardCharsets.UTF_8));
}
private void cleanupAfterFailedStore(String fileId, Path filePath) {
try {
Files.deleteIfExists(filePath);
} catch (IOException cleanupEx) {
log.warn("Failed to clean up partial file {} after store failure", filePath, cleanupEx);
}
try {
Files.deleteIfExists(resolveOwner(fileId));
} catch (IOException cleanupEx) {
log.warn("Failed to clean up owner sidecar for {} after store failure", fileId);
}
}
@@ -101,11 +135,26 @@ public class LocalDiskFileStore implements FileStore {
@Override
public boolean delete(String fileId) {
ReentrantLock lock = acquire(fileId);
try {
return Files.deleteIfExists(resolve(fileId));
} catch (IOException e) {
log.error("Error deleting file with ID: {}", fileId, e);
return false;
// Data first, owner second: a concurrent retrieve that observes the transient
// (data-gone, owner-still-present) window simply fails with IOException; the inverse
// order would briefly look like an unowned file and could grant cross-user access.
boolean removed;
try {
removed = Files.deleteIfExists(resolve(fileId));
} catch (IOException e) {
log.error("Error deleting file with ID: {}", fileId, e);
return false;
}
try {
Files.deleteIfExists(resolveOwner(fileId));
} catch (IOException e) {
log.warn("Error deleting owner sidecar for file ID: {}", fileId, e);
}
return removed;
} finally {
release(fileId, lock);
}
}
@@ -114,8 +163,21 @@ public class LocalDiskFileStore implements FileStore {
return Files.exists(resolve(fileId));
}
@Override
public String getOwner(String fileId) throws IOException {
Path ownerPath = resolveOwner(fileId);
if (!Files.exists(ownerPath)) {
return null;
}
byte[] bytes = Files.readAllBytes(ownerPath);
if (bytes.length == 0) {
return null;
}
return new String(bytes, StandardCharsets.UTF_8);
}
public Path resolve(String fileId) {
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
if (fileId == null || !UUID_PATTERN.matcher(fileId).matches()) {
throw new IllegalArgumentException("Invalid file ID");
}
Path basePath = Path.of(baseDirPath).normalize().toAbsolutePath();
@@ -125,4 +187,19 @@ public class LocalDiskFileStore implements FileStore {
}
return resolvedPath;
}
private Path resolveOwner(String fileId) {
Path data = resolve(fileId);
return data.resolveSibling(data.getFileName().toString() + OWNER_SUFFIX);
}
private ReentrantLock acquire(String fileId) {
ReentrantLock lock = stripes[(fileId.hashCode() & Integer.MAX_VALUE) % LOCK_STRIPES];
lock.lock();
return lock;
}
private void release(String fileId, ReentrantLock lock) {
lock.unlock();
}
}
@@ -3,7 +3,6 @@ package stirling.software.common.configuration;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
@@ -122,17 +121,17 @@ public class AppConfig {
@Bean(name = "RunningInDocker")
public boolean runningInDocker() {
return Files.exists(Paths.get("/.dockerenv"));
return Files.exists(Path.of("/.dockerenv"));
}
@Bean(name = "configDirMounted")
public boolean isRunningInDockerWithConfig() {
Path dockerEnv = Paths.get("/.dockerenv");
Path dockerEnv = Path.of("/.dockerenv");
// default to true if not docker
if (!Files.exists(dockerEnv)) {
return true;
}
Path mountInfo = Paths.get("/proc/1/mountinfo");
Path mountInfo = Path.of("/proc/1/mountinfo");
// this should always exist, if not some unknown usecase
if (!Files.exists(mountInfo)) {
return true;
@@ -7,7 +7,6 @@ import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.List;
@@ -27,7 +26,7 @@ public class ConfigInitializer {
public void ensureConfigExists() throws IOException, URISyntaxException {
// 1) If settings file doesn't exist, create from template
Path destPath = Paths.get(InstallationPathConfig.getSettingsPath());
Path destPath = Path.of(InstallationPathConfig.getSettingsPath());
boolean settingsFileExists = Files.exists(destPath);
@@ -39,7 +38,7 @@ public class ConfigInitializer {
if (settingsFileExists) {
// move settings.yml to settings.yml.{timestamp}.bak
Path backupPath =
Paths.get(
Path.of(
InstallationPathConfig.getSettingsPath()
+ "."
+ System.currentTimeMillis()
@@ -96,7 +95,7 @@ public class ConfigInitializer {
}
// 3) Ensure custom settings file exists
Path customSettingsPath = Paths.get(InstallationPathConfig.getCustomSettingsPath());
Path customSettingsPath = Path.of(InstallationPathConfig.getCustomSettingsPath());
if (Files.notExists(customSettingsPath)) {
Files.createFile(customSettingsPath);
log.info("Created custom_settings file: {}", customSettingsPath);
@@ -3,7 +3,6 @@ package stirling.software.common.configuration;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
@@ -201,7 +200,7 @@ public class RuntimePathConfig {
try {
// Normalize to absolute path
Path path = Paths.get(pathStr.trim()).toAbsolutePath().normalize();
Path path = Path.of(pathStr.trim()).toAbsolutePath().normalize();
String normalizedPath = path.toString();
// Check for duplicates
@@ -224,9 +223,9 @@ public class RuntimePathConfig {
private void detectOverlappingPaths(List<String> paths) {
for (int i = 0; i < paths.size(); i++) {
Path path1 = Paths.get(paths.get(i));
Path path1 = Path.of(paths.get(i));
for (int j = i + 1; j < paths.size(); j++) {
Path path2 = Paths.get(paths.get(j));
Path path2 = Path.of(paths.get(j));
// Check if one path is a parent of the other
if (path1.startsWith(path2)) {
@@ -246,10 +245,10 @@ public class RuntimePathConfig {
private void validatePipelinePaths() {
try {
Path finishedPath = Paths.get(pipelineFinishedFoldersPath).toAbsolutePath().normalize();
Path finishedPath = Path.of(pipelineFinishedFoldersPath).toAbsolutePath().normalize();
for (String watchedPathStr : pipelineWatchedFoldersPaths) {
Path watchedPath = Paths.get(watchedPathStr).toAbsolutePath().normalize();
Path watchedPath = Path.of(watchedPathStr).toAbsolutePath().normalize();
// Check if watched folder is same as finished folder
if (watchedPath.equals(finishedPath)) {
@@ -77,8 +77,10 @@ public class ApplicationProperties {
private ProcessExecutor processExecutor = new ProcessExecutor();
private PdfEditor pdfEditor = new PdfEditor();
private AiEngine aiEngine = new AiEngine();
private Mcp mcp = new Mcp();
private InternalApi internalApi = new InternalApi();
private Cluster cluster = new Cluster();
private Policies policies = new Policies();
@Bean
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
@@ -202,6 +204,45 @@ public class ApplicationProperties {
}
}
@Data
public static class Policies {
/**
* Absolute directories that policy folder input sources and output sinks may read from or
* write to. Empty (the default) disables folder access entirely, so a policy can never be
* pointed at an arbitrary server path. Stirling's own config directory is always
* off-limits, and folder access is always disabled in SaaS mode regardless of this list.
*/
private List<String> allowedFolderRoots = new java.util.ArrayList<>();
/** How often (seconds) the schedule trigger checks for policies whose schedule is due. */
private long scheduleSweepSeconds = 60;
/**
* How often (seconds) the folder-watch trigger reconciles its watch registrations and
* re-runs every folder-watch policy as a safety net for filesystem events that were missed
* (NFS, bind mounts, inotify-queue overflow).
*/
private long watchReconcileSeconds = 300;
/**
* How long (milliseconds) the folder-watch trigger keeps draining filesystem events after
* the first, so a burst from a single file copy coalesces into one run instead of many.
*/
private long watchQuietPeriodMs = 500;
/**
* SSE emitter timeout (milliseconds) for streamed runs; generous for long multi-step runs.
*/
private long streamTimeoutMs = 1800000;
/**
* How long (minutes) a finished run's in-memory state is retained before eviction,
* mirroring the job-result expiry so rich run state does not outlive the process. Active
* and paused runs are kept regardless of age.
*/
private int runExpiryMinutes = 30;
}
@Data
public static class PdfEditor {
private Cache cache = new Cache();
@@ -256,6 +297,103 @@ public class ApplicationProperties {
private int longRunningTimeoutSeconds = 600;
}
/**
* Model Context Protocol (MCP) server configuration. All keys live under the top-level {@code
* mcp.*} prefix. {@link #enabled} defaults to {@code false}: when off, no MCP beans are wired,
* no /mcp endpoint exists, and no protected-resource metadata is published.
*/
@Data
public static class Mcp {
/** Master switch. When {@code false} (default), no MCP beans are wired. */
private boolean enabled = false;
/**
* When {@code true} (default), invocations require an OAuth scope: {@code mcp.tools.read}
* for read-style operations and {@code mcp.tools.write} for write/destructive ones. When
* {@code false}, scope checks are skipped (use only if your IdP issues a single coarse
* scope).
*/
private boolean scopesEnabled = true;
/** How often to refresh the AI capabilities manifest from the engine. */
private int engineCapabilityRefreshMinutes = 5;
/**
* Tool allow-list (operation ids, e.g. {@code compress-pdf}). When non-empty, ONLY these
* operations are exposed over MCP; everything else is hidden, undescribable, and
* uninvocable - on top of the global endpoint enable/disable config. Empty = allow all.
*/
private List<String> allowedOperations = new ArrayList<>();
/**
* Tool deny-list (operation ids). Any operation listed here is removed from MCP even if it
* would otherwise be allowed. Applied after {@link #allowedOperations}.
*/
private List<String> blockedOperations = new ArrayList<>();
/** Max MCP request body size in bytes; inline file uploads ride in the JSON-RPC body. */
private long maxRequestBytes = 10L * 1024 * 1024;
/** Results up to this size return inline as base64; larger ones return a fileId only. */
private long maxInlineResponseBytes = 10L * 1024 * 1024;
private Auth auth = new Auth();
@Data
public static class Auth {
/**
* Authentication mode for the MCP endpoint. {@code oauth} (default) runs a full OAuth2
* resource server (JWT, RFC 8707 audience, RFC 9728 metadata). {@code apikey} accepts a
* Stirling per-user API key via the {@code X-API-KEY} header (or {@code Authorization:
* Bearer <key>}) and binds the request to that user - the low-friction self-host path,
* no external IdP required.
*/
private String mode = "oauth";
/** OAuth2 issuer URI, e.g. {@code http://localhost:9000}. Required when MCP is on. */
private String issuerUri = "";
/**
* JWKS URI. When blank, derived from the issuer's {@code
* /.well-known/openid-configuration} document.
*/
private String jwksUri = "";
/**
* RFC 8707 resource identifier of THIS MCP server, e.g. {@code
* http://localhost:8080/mcp}. Tokens that do not list this id in their {@code aud}
* claim are rejected with HTTP 401.
*/
private String resourceId = "";
/**
* Additional JWT audiences accepted at the MCP endpoint, on top of {@link #resourceId}.
* Empty (default) keeps strict RFC 8707 binding. Some IdPs cannot mint
* resource-specific audiences - e.g. Supabase's OAuth server always issues {@code
* aud=authenticated} - so operators list the audience their IdP actually emits here
* (env: {@code MCP_AUTH_ACCEPTEDAUDIENCES}, comma-separated).
*/
private List<String> acceptedAudiences = new ArrayList<>();
/**
* JWT claim whose value is matched against a provisioned Stirling username. Defaults to
* {@code sub}; set to {@code email} or {@code preferred_username} to match how your IdP
* maps users to Stirling accounts.
*/
private String usernameClaim = "sub";
/**
* When {@code true} (default), a validated token is accepted only if its {@link
* #usernameClaim} value resolves to an existing, enabled Stirling user account. Tokens
* whose subject has no Stirling account (or a disabled one) are rejected with HTTP 403.
* Set to {@code false} only if you intentionally want any IdP-valid token to use MCP
* without a local account.
*/
private boolean requireExistingAccount = true;
}
}
/**
* Cluster backplane configuration. All keys live under the top-level {@code cluster.*} prefix
* (e.g. env var {@code CLUSTER_ENABLED}). The master switch is {@link #enabled} and defaults to
@@ -867,6 +1005,10 @@ public class ApplicationProperties {
@Data
public static class Signing {
private boolean enabled = false;
// Signing user-picker scope: 'org' (default) = whole instance, anything else =
// caller's team only (fail-closed). The saas profile pins 'team'.
private String userListScope = "org";
}
}
@@ -1,7 +1,6 @@
package stirling.software.common.model;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
@@ -24,7 +23,7 @@ public class FileInfo {
// Converts the file path string to a Path object.
public Path getFilePathAsPath() {
return Paths.get(filePath);
return Path.of(filePath);
}
// Formats the file size into a human-readable string.
@@ -0,0 +1,191 @@
package stirling.software.common.pdf;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import stirling.software.jpdfium.text.PageText;
import stirling.software.jpdfium.text.TextChar;
import stirling.software.jpdfium.text.TextLine;
import stirling.software.jpdfium.text.TextWord;
final class HeadingDetector {
private HeadingDetector() {}
/** A heading is at most this many words; longer lines are treated as body text. */
private static final int MAX_HEADING_WORDS = 12;
/**
* Returns the Markdown heading prefix for a line. The decision combines several signals, never
* text matching, so a plain line that merely shares text with a heading is never promoted:
*
* <ul>
* <li><b>Size</b> — dominant glyph font size vs. the document body median (primary signal).
* Some PDFs encode visual size in the text matrix, so every glyph reports ~1.0; for those
* the line height is used as the proxy instead.
* <li><b>Brevity</b> — headings are short labels; a line over {@value #MAX_HEADING_WORDS}
* words is body text regardless of size.
* <li><b>Not a sentence</b> — a line ending in {@code . ! ?} reads as prose, not a heading.
* </ul>
*
* <p>Boldness is deliberately <em>not</em> a heading signal — a bold-but-not-larger line is
* emphasis, not a heading (see {@link #isBoldLabel}); promoting it to {@code #}/{@code ##} is
* the main source of false-positive headings.
*
* <ul>
* <li>size &gt; baseline * 1.4 → {@code "# "}
* <li>size &gt; baseline * 1.2 → {@code "## "}
* <li>otherwise → {@code ""}
* </ul>
*/
static String headingPrefix(TextLine line, float medianBodySize, float medianBodyHeight) {
String text = line.text().strip();
if (text.isEmpty() || wordCount(text) > MAX_HEADING_WORDS || endsLikeSentence(text)) {
return "";
}
float dominant = dominantFontSize(line);
float value;
float baseline;
if (dominant > 2f && medianBodySize > 2f) {
value = dominant;
baseline = medianBodySize;
} else {
value = line.height();
baseline = medianBodyHeight;
}
if (baseline <= 0f) {
return "";
}
float ratio = value / baseline;
if (ratio > 1.4f) {
return "# ";
}
if (ratio > 1.2f) {
return "## ";
}
return "";
}
/**
* True when a line should be emphasised as bold (rendered {@code **like this**}) rather than
* promoted to a heading: it is bold, short, and not a full sentence. Used for bold labels that
* are not large enough to be headings.
*/
static boolean isBoldLabel(TextLine line) {
String text = line.text().strip();
if (text.isEmpty() || wordCount(text) > MAX_HEADING_WORDS || endsLikeSentence(text)) {
return false;
}
return isBold(line);
}
private static int wordCount(String text) {
return text.split("\\s+").length;
}
private static boolean endsLikeSentence(String text) {
char last = text.charAt(text.length() - 1);
return last == '.' || last == '!' || last == '?';
}
/** True when the line's dominant font is bold, inferred from PostScript font names. */
private static boolean isBold(TextLine line) {
Map<String, Integer> counts = new HashMap<>();
for (TextWord word : line.words()) {
for (TextChar ch : word.chars()) {
if (ch.isWhitespace() || ch.isNewline()) {
continue;
}
String name = ch.fontName();
if (name != null && !name.isBlank()) {
counts.merge(name, 1, Integer::sum);
}
}
}
String dominantFont = "";
int max = -1;
for (Map.Entry<String, Integer> e : counts.entrySet()) {
if (e.getValue() > max) {
max = e.getValue();
dominantFont = e.getKey();
}
}
String lower = dominantFont.toLowerCase(java.util.Locale.ROOT);
return lower.contains("bold")
|| lower.contains("black")
|| lower.contains("heavy")
|| lower.contains("semibold");
}
/** Computes the median glyph font size across all pages. */
static float medianFontSize(List<PageText> allPages) {
List<Float> sizes = new ArrayList<>();
for (PageText page : allPages) {
for (TextChar ch : page.chars()) {
if (!ch.isWhitespace() && !ch.isNewline() && ch.fontSize() > 0f) {
sizes.add(ch.fontSize());
}
}
}
return median(sizes, 12f);
}
/** Computes the median TextLine height across all pages. Used when font size is degenerate. */
static float medianLineHeight(List<PageText> allPages) {
List<Float> heights = new ArrayList<>();
for (PageText page : allPages) {
for (TextLine line : page.lines()) {
if (line.height() > 0f && !line.text().isBlank()) {
heights.add(line.height());
}
}
}
return median(heights, 12f);
}
private static float median(List<Float> values, float fallback) {
if (values.isEmpty()) {
return fallback;
}
Collections.sort(values);
int mid = values.size() / 2;
if (values.size() % 2 == 0) {
return (values.get(mid - 1) + values.get(mid)) / 2f;
}
return values.get(mid);
}
/**
* Returns the font size that appears most often (by character count) in the given line. Ties
* are broken in favour of the larger size.
*/
private static float dominantFontSize(TextLine line) {
Map<Float, Integer> counts = new HashMap<>();
for (TextWord word : line.words()) {
for (TextChar ch : word.chars()) {
if (!ch.isWhitespace() && !ch.isNewline() && ch.fontSize() > 0f) {
counts.merge(ch.fontSize(), 1, Integer::sum);
}
}
}
if (counts.isEmpty()) {
return 0f;
}
float dominant = 0f;
int maxCount = -1;
for (Map.Entry<Float, Integer> entry : counts.entrySet()) {
int count = entry.getValue();
float size = entry.getKey();
if (count > maxCount || (count == maxCount && size > dominant)) {
maxCount = count;
dominant = size;
}
}
return dominant;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
package stirling.software.common.pdf;
import stirling.software.jpdfium.text.Table;
final class TableRenderer {
private TableRenderer() {}
/** Renders a Table as a GitHub-Flavoured Markdown table string. */
static String render(Table table) {
if (table.rowCount() == 0) {
return "";
}
String[][] grid = table.asGrid();
if (table.rowCount() < 2) {
// No separator row possible — return plain lines
StringBuilder sb = new StringBuilder();
for (int c = 0; c < grid[0].length; c++) {
if (c > 0) sb.append('\n');
sb.append(escape(grid[0][c].trim()));
}
return sb.toString();
}
int cols = grid[0].length;
// Compute column widths: max(3, max content length across all rows)
int[] widths = new int[cols];
for (int c = 0; c < cols; c++) {
widths[c] = 3;
}
for (String[] row : grid) {
for (int c = 0; c < cols; c++) {
String cell = c < row.length ? row[c].trim() : "";
widths[c] = Math.max(widths[c], escape(cell).length());
}
}
StringBuilder sb = new StringBuilder();
// Header row
sb.append(buildRow(grid[0], widths, cols));
sb.append('\n');
// Separator row
sb.append('|');
for (int c = 0; c < cols; c++) {
sb.append('-').append("-".repeat(widths[c])).append('-').append('|');
}
sb.append('\n');
// Data rows
for (int r = 1; r < grid.length; r++) {
sb.append(buildRow(grid[r], widths, cols));
if (r < grid.length - 1) {
sb.append('\n');
}
}
return sb.toString();
}
private static String buildRow(String[] row, int[] widths, int cols) {
StringBuilder sb = new StringBuilder();
sb.append('|');
for (int c = 0; c < cols; c++) {
String cell = c < row.length ? escape(row[c].trim()) : "";
sb.append(' ').append(padRight(cell, widths[c])).append(' ').append('|');
}
return sb.toString();
}
private static String escape(String cell) {
return cell.replace("|", "\\|");
}
private static String padRight(String s, int width) {
if (s.length() >= width) return s;
return s + " ".repeat(width - s.length());
}
}
@@ -5,6 +5,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.util.Optional;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;
@@ -17,6 +18,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.FileStore;
import stirling.software.common.util.JobContext;
/**
* Service for storing and retrieving files with unique file IDs. Used by the AutoJobPostMapping
@@ -32,8 +34,10 @@ public class FileStorage {
private final FileOrUploadService fileOrUploadService;
private final FileStore fileStore;
private final Optional<JobOwnershipService> jobOwnershipService;
public String storeFile(MultipartFile file) throws IOException {
String owner = resolveOwner();
// Fast path: when Spring buffered the multipart to disk (typical for large uploads), the
// backing Resource exposes a real File. Hand the Path to the FileStore so it can do a
// file-to-file copy (Linux sendfile, no copy through Java heap) rather than streaming
@@ -48,7 +52,7 @@ public class FileStorage {
if (res != null && res.isFile()) {
try {
FileStore.Stored stored =
fileStore.store(res.getFile().toPath(), file.getOriginalFilename());
fileStore.store(res.getFile().toPath(), file.getOriginalFilename(), owner);
log.debug("Stored file with ID: {} (fast path)", stored.fileId());
return stored.fileId();
} catch (IOException ex) {
@@ -57,40 +61,45 @@ public class FileStorage {
}
}
try (InputStream in = file.getInputStream()) {
FileStore.Stored stored = fileStore.store(in, file.getOriginalFilename());
FileStore.Stored stored = fileStore.store(in, file.getOriginalFilename(), owner);
log.debug("Stored file with ID: {}", stored.fileId());
return stored.fileId();
}
}
public String storeBytes(byte[] bytes, String originalName) throws IOException {
FileStore.Stored stored = fileStore.store(new ByteArrayInputStream(bytes), originalName);
FileStore.Stored stored =
fileStore.store(new ByteArrayInputStream(bytes), originalName, resolveOwner());
log.debug("Stored byte array with ID: {}", stored.fileId());
return stored.fileId();
}
public MultipartFile retrieveFile(String fileId) throws IOException {
enforceOwnership(fileId);
byte[] fileData = fileStore.retrieveBytes(fileId);
return fileOrUploadService.toMockMultipartFile(fileId, fileData);
}
public byte[] retrieveBytes(String fileId) throws IOException {
enforceOwnership(fileId);
return fileStore.retrieveBytes(fileId);
}
public InputStream retrieveInputStream(String fileId) throws IOException {
enforceOwnership(fileId);
return fileStore.retrieve(fileId);
}
public StoredFile storeInputStream(InputStream inputStream, String originalName)
throws IOException {
FileStore.Stored stored = fileStore.store(inputStream, originalName);
FileStore.Stored stored = fileStore.store(inputStream, originalName, resolveOwner());
log.debug("Stored input stream with ID: {}", stored.fileId());
return new StoredFile(stored.fileId(), stored.size());
}
public String storeFromStreamingBody(StreamingResponseBody body, String originalName)
throws IOException {
String owner = resolveOwner();
// Hold Throwable not IOException: an unchecked failure (NPE, IllegalState, OOM, etc.)
// from the body writer would otherwise close the pipe with EOF and the consumer would
// return a truncated file with no error surfaced to the caller.
@@ -115,7 +124,7 @@ public class FileStorage {
}
}
});
FileStore.Stored stored = fileStore.store(in, originalName);
FileStore.Stored stored = fileStore.store(in, originalName, owner);
Throwable writerErr = bodyError.get();
if (writerErr != null) {
// Body failed mid-write: the FileStore persisted a truncated entry.
@@ -159,21 +168,62 @@ public class FileStorage {
public String storeFromResource(Resource resource, String originalName) throws IOException {
try (InputStream in = resource.getInputStream()) {
FileStore.Stored stored = fileStore.store(in, originalName);
FileStore.Stored stored = fileStore.store(in, originalName, resolveOwner());
log.debug("Stored Resource with ID: {}", stored.fileId());
return stored.fileId();
}
}
public boolean deleteFile(String fileId) {
enforceOwnership(fileId);
return fileStore.delete(fileId);
}
public boolean fileExists(String fileId) {
enforceOwnership(fileId);
return fileStore.exists(fileId);
}
public long getFileSize(String fileId) throws IOException {
enforceOwnership(fileId);
return fileStore.size(fileId);
}
private String resolveOwner() {
String propagated = JobContext.getOwner();
if (propagated != null) {
return propagated;
}
return jobOwnershipService.flatMap(JobOwnershipService::getCurrentUserId).orElse(null);
}
private void enforceOwnership(String fileId) {
if (jobOwnershipService.isEmpty()) {
return;
}
Optional<String> currentUser = jobOwnershipService.get().getCurrentUserId();
if (currentUser.isEmpty()) {
return;
}
String owner;
try {
owner = fileStore.getOwner(fileId);
} catch (IOException e) {
log.warn("Failed to read owner for file {}: {}", fileId, e.getMessage());
throw new SecurityException(
"Access denied: could not verify ownership of the requested file");
}
if (owner == null) {
return;
}
if (!owner.equals(currentUser.get())) {
log.warn(
"Access denied: user {} attempted to access file {} owned by {}",
currentUser.get(),
fileId,
owner);
throw new SecurityException(
"Access denied: you do not have permission to access this file");
}
}
}
@@ -50,6 +50,16 @@ public class InternalApiClient {
"^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$"
+ "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
/**
* Marker propagated on every internal sub-step dispatch so the saas PAYG interceptor classifies
* the call as {@code BillingCategory.AUTOMATION}. By construction every {@link
* InternalApiClient#post} caller is an automation surface (pipeline executor, AI workflow,
* policy runner) running a child tool inside a parent automation flow — see the saas {@code
* PaygChargeInterceptor.determineCategory} precedence chain, where this header dominates any
* per-tool {@code @RequiresFeature} annotation.
*/
public static final String AUTOMATION_HEADER = "X-Stirling-Automation";
private final ServletContext servletContext;
private final UserServiceInterface userService;
private final TempFileManager tempFileManager;
@@ -96,7 +106,23 @@ public class InternalApiClient {
if (apiKey != null && !apiKey.isEmpty()) {
headers.add("X-API-KEY", apiKey);
}
// Tag the sub-step as automation so PAYG bills it under AUTOMATION regardless of which
// tool-level @RequiresFeature annotation the dispatched controller carries (e.g. an AI-OCR
// step inside a policy run must bill as AUTOMATION, not AI). Set unconditionally because
// every caller of this dispatcher is an automation surface by design.
headers.add(AUTOMATION_HEADER, "true");
// A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so
// without this RestTemplate would use urlencoded instead of the multipart the controller
// expects. File-bearing calls get the right multipart content-type from RestTemplate.
boolean isAiTool = endpointPath.startsWith("/api/v1/ai/tools/");
boolean hasFilePart =
body.values().stream()
.flatMap(java.util.List::stream)
.anyMatch(v -> v instanceof Resource);
if (isAiTool && !hasFilePart) {
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
}
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class);
@@ -89,6 +89,11 @@ public class JobExecutorService {
String jobId = scopedJobKey;
final String jobOwner =
jobOwnershipService != null
? jobOwnershipService.getCurrentUserId().orElse(null)
: null;
long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs;
log.debug(
@@ -119,6 +124,7 @@ public class JobExecutorService {
try {
stirling.software.common.util.JobContext.setJobId(
capturedJobIdForQueue);
stirling.software.common.util.JobContext.setOwner(jobOwner);
Object result = work.get();
processJobResult(capturedJobIdForQueue, result);
return result;
@@ -153,6 +159,7 @@ public class JobExecutorService {
timeoutToUse);
stirling.software.common.util.JobContext.setJobId(capturedJobId);
stirling.software.common.util.JobContext.setOwner(jobOwner);
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
processJobResult(capturedJobId, result);
} catch (TimeoutException te) {
@@ -3,7 +3,6 @@ package stirling.software.common.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -35,7 +34,7 @@ public class MobileScannerService {
public MobileScannerService() throws IOException {
// Create temp directory for mobile scanner uploads
this.tempDirectory =
Paths.get(System.getProperty("java.io.tmpdir"), "stirling-mobile-scanner");
Path.of(System.getProperty("java.io.tmpdir"), "stirling-mobile-scanner");
Files.createDirectories(tempDirectory);
log.info("Mobile scanner temp directory: {}", tempDirectory);
}
@@ -8,7 +8,7 @@ import java.lang.management.OperatingSystemMXBean;
import java.lang.management.RuntimeMXBean;
import java.lang.management.ThreadMXBean;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
@@ -160,7 +160,7 @@ public class PostHogService {
}
private boolean isRunningInDocker() {
return Files.exists(Paths.get("/.dockerenv"));
return Files.exists(Path.of("/.dockerenv"));
}
private Map<String, Object> getDockerMetrics() {
@@ -255,7 +255,7 @@ public class GeneralUtils {
String pattern = locationPattern;
if (pattern.startsWith("file:")) {
String rawPath = pattern.substring(5).replace("\\*", "").replace("/*", "");
Path normalizePath = Paths.get(rawPath).normalize();
Path normalizePath = Path.of(rawPath).normalize();
pattern = "file:" + normalizePath.toString().replace("\\", "/") + "/*";
}
return ResourcePatternUtils.getResourcePatternResolver(resourceLoader)
@@ -837,7 +837,7 @@ public class GeneralUtils {
}
public boolean createDir(String path) {
Path folder = Paths.get(path);
Path folder = Path.of(path);
if (!Files.exists(folder)) {
try {
Files.createDirectories(folder);
@@ -867,7 +867,7 @@ public class GeneralUtils {
public void saveKeyToSettings(String key, Object newValue) throws IOException {
String[] keyArray = key.split("\\.");
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
Path settingsPath = Path.of(InstallationPathConfig.getSettingsPath());
YamlHelper settingsYaml = new YamlHelper(settingsPath);
settingsYaml.updateValue(Arrays.asList(keyArray), newValue);
settingsYaml.saveOverride(settingsPath);
@@ -888,7 +888,7 @@ public class GeneralUtils {
return;
}
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
Path settingsPath = Path.of(InstallationPathConfig.getSettingsPath());
YamlHelper settingsYaml = new YamlHelper(settingsPath);
// Apply all updates to the same YamlHelper instance
@@ -974,11 +974,11 @@ public class GeneralUtils {
*/
public void extractPipeline() throws IOException {
Path pipelineDir =
Paths.get(InstallationPathConfig.getPipelinePath(), DEFAULT_WEBUI_CONFIGS_DIR);
Path.of(InstallationPathConfig.getPipelinePath(), DEFAULT_WEBUI_CONFIGS_DIR);
Files.createDirectories(pipelineDir);
for (String name : DEFAULT_VALID_PIPELINE) {
if (!Paths.get(name).getFileName().toString().equals(name)) {
if (!Path.of(name).getFileName().toString().equals(name)) {
log.error("Invalid pipeline file name: {}", name);
throw new IllegalArgumentException("Invalid pipeline file name: " + name);
}
@@ -1014,7 +1014,7 @@ public class GeneralUtils {
throw new IllegalArgumentException(
"scriptName must not contain path traversal characters");
}
if (!Paths.get(scriptName).getFileName().toString().equals(scriptName)) {
if (!Path.of(scriptName).getFileName().toString().equals(scriptName)) {
throw new IllegalArgumentException(
"scriptName must not contain path traversal characters");
}
@@ -1024,7 +1024,7 @@ public class GeneralUtils {
"scriptName must be either 'png_to_webp.py' or 'split_photos.py'");
}
Path scriptsDir = Paths.get(InstallationPathConfig.getScriptsPath(), PYTHON_SCRIPTS_DIR);
Path scriptsDir = Path.of(InstallationPathConfig.getScriptsPath(), PYTHON_SCRIPTS_DIR);
Files.createDirectories(scriptsDir);
Path target = scriptsDir.resolve(scriptName);
@@ -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) {}
}
@@ -4,7 +4,6 @@ import java.io.File;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import lombok.extern.slf4j.Slf4j;
@@ -20,7 +19,7 @@ public class JarPathUtil {
public static Path currentJar() {
try {
Path jar =
Paths.get(
Path.of(
JarPathUtil.class
.getProtectionDomain()
.getCodeSource()
@@ -61,14 +60,14 @@ public class JarPathUtil {
}
// Location 2: ./build/libs/ (development build)
possibleLocations[1] = Paths.get("build", "libs", "restart-helper.jar").toAbsolutePath();
possibleLocations[1] = Path.of("build", "libs", "restart-helper.jar").toAbsolutePath();
// Location 3: app/common/build/libs/ (multi-module build)
possibleLocations[2] =
Paths.get("app", "common", "build", "libs", "restart-helper.jar").toAbsolutePath();
Path.of("app", "common", "build", "libs", "restart-helper.jar").toAbsolutePath();
// Location 4: Current working directory
possibleLocations[3] = Paths.get("restart-helper.jar").toAbsolutePath();
possibleLocations[3] = Path.of("restart-helper.jar").toAbsolutePath();
// Check each location
for (Path location : possibleLocations) {
@@ -1,8 +1,9 @@
package stirling.software.common.util;
/** Thread-local context for passing job ID across async boundaries */
/** Thread-local context for passing job ID and owner across async boundaries */
public class JobContext {
private static final ThreadLocal<String> CURRENT_JOB_ID = new ThreadLocal<>();
private static final ThreadLocal<String> CURRENT_OWNER = new ThreadLocal<>();
public static void setJobId(String jobId) {
CURRENT_JOB_ID.set(jobId);
@@ -12,7 +13,16 @@ public class JobContext {
return CURRENT_JOB_ID.get();
}
public static void setOwner(String owner) {
CURRENT_OWNER.set(owner);
}
public static String getOwner() {
return CURRENT_OWNER.get();
}
public static void clear() {
CURRENT_JOB_ID.remove();
CURRENT_OWNER.remove();
}
}
@@ -0,0 +1,481 @@
package stirling.software.SPDF.config;
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.assertTrue;
import java.util.List;
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 stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
import stirling.software.common.model.ApplicationProperties;
/**
* Unit tests for {@link EndpointConfiguration}. The class wires up its endpoint/group registry in
* {@code init()} during construction and then applies environment overrides. We build it with a
* real {@link ApplicationProperties} (whose System/Endpoints sub-objects are non-null by default)
* so the constructor runs cleanly without any mocking.
*/
class EndpointConfigurationGapTest {
private ApplicationProperties applicationProperties;
/**
* Construct an EndpointConfiguration with the given pro flag and current applicationProperties.
*/
private EndpointConfiguration build(boolean runningProOrHigher) {
return new EndpointConfiguration(applicationProperties, runningProOrHigher);
}
/** Default config: not pro, no removals, url-to-pdf disabled (default System flag is false). */
private EndpointConfiguration buildDefault() {
return build(false);
}
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
}
@Nested
@DisplayName("endpointKeyForUri (static)")
class EndpointKeyForUriTests {
@Test
@DisplayName("returns null for null uri")
void nullUri() {
assertNull(EndpointConfiguration.endpointKeyForUri(null));
}
@Test
@DisplayName("returns null when uri does not contain /api/v1")
void notApiPath() {
assertNull(EndpointConfiguration.endpointKeyForUri("/foo/bar/baz"));
assertNull(EndpointConfiguration.endpointKeyForUri("https://example.com/home"));
}
@Test
@DisplayName("returns null when uri has too few path segments")
void tooFewSegments() {
// "/api/v1/general" splits to ["", "api", "v1", "general"] -> length 4, not > 4
assertNull(EndpointConfiguration.endpointKeyForUri("/api/v1/general"));
}
@Test
@DisplayName("extracts plain endpoint key from a standard /api/v1/<group>/<endpoint> uri")
void plainEndpoint() {
assertEquals(
"remove-pages",
EndpointConfiguration.endpointKeyForUri("/api/v1/general/remove-pages"));
}
@Test
@DisplayName("builds a <from>-to-<to> key for convert endpoints")
void convertEndpoint() {
assertEquals(
"pdf-to-img",
EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf/img"));
}
@Test
@DisplayName("convert path without a target segment falls back to the segment after group")
void convertWithoutTarget() {
// "/api/v1/convert/pdf" -> length 5, the convert branch needs length > 5
assertEquals("pdf", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf"));
}
}
@Nested
@DisplayName("enable / disable endpoint")
class EnableDisableEndpointTests {
@Test
@DisplayName("a freshly registered endpoint is enabled by default")
void enabledByDefault() {
EndpointConfiguration config = buildDefault();
assertTrue(config.isEndpointEnabled("merge-pdfs"));
}
@Test
@DisplayName("disableEndpoint marks the endpoint disabled")
void disableEndpoint() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs");
assertFalse(config.isEndpointEnabled("merge-pdfs"));
}
@Test
@DisplayName("enableEndpoint re-enables a previously disabled endpoint")
void reEnableEndpoint() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs");
assertFalse(config.isEndpointEnabled("merge-pdfs"));
config.enableEndpoint("merge-pdfs");
assertTrue(config.isEndpointEnabled("merge-pdfs"));
}
@Test
@DisplayName("leading slash is normalized away on disable")
void leadingSlashNormalizedOnDisable() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("/merge-pdfs");
// both forms resolve to the same key
assertFalse(config.isEndpointEnabled("merge-pdfs"));
assertFalse(config.isEndpointEnabled("/merge-pdfs"));
}
@Test
@DisplayName("isEndpointEnabled tolerates a leading slash on the query")
void leadingSlashOnQuery() {
EndpointConfiguration config = buildDefault();
assertTrue(config.isEndpointEnabled("/merge-pdfs"));
}
@Test
@DisplayName("disabling clears with enable, removing the disable reason")
void enableClearsReason() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("split-pages", DisableReason.DEPENDENCY);
assertEquals(
DisableReason.DEPENDENCY,
config.getEndpointAvailability("split-pages").getReason());
config.enableEndpoint("split-pages");
EndpointAvailability availability = config.getEndpointAvailability("split-pages");
assertTrue(availability.isEnabled());
assertNull(availability.getReason());
}
}
@Nested
@DisplayName("isEndpointEnabledForUri")
class IsEndpointEnabledForUriTests {
@Test
@DisplayName("translates a /api/v1 uri to a key and reports its status")
void translatesUri() {
EndpointConfiguration config = buildDefault();
assertTrue(config.isEndpointEnabledForUri("/api/v1/general/merge-pdfs"));
config.disableEndpoint("merge-pdfs");
assertFalse(config.isEndpointEnabledForUri("/api/v1/general/merge-pdfs"));
}
@Test
@DisplayName("falls back to treating a non-api uri as a raw key")
void fallsBackToRawKey() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs");
// non-api path: key resolution returns null, so the uri itself is used as the key
assertFalse(config.isEndpointEnabledForUri("merge-pdfs"));
}
}
@Nested
@DisplayName("group enable / disable")
class GroupTests {
@Test
@DisplayName("a functional group with all endpoints enabled reports enabled")
void functionalGroupEnabled() {
EndpointConfiguration config = buildDefault();
assertTrue(config.isGroupEnabled("PageOps"));
}
@Test
@DisplayName("disabling a functional group cascades to all its endpoints")
void disableFunctionalGroupCascades() {
EndpointConfiguration config = buildDefault();
config.disableGroup("PageOps");
assertFalse(config.isGroupEnabled("PageOps"));
assertFalse(config.isEndpointEnabled("remove-pages"));
assertFalse(config.isEndpointEnabled("split-pages"));
}
@Test
@DisplayName("re-enabling a functional group re-enables its endpoints")
void enableFunctionalGroupRestores() {
EndpointConfiguration config = buildDefault();
config.disableGroup("PageOps");
assertFalse(config.isEndpointEnabled("remove-pages"));
config.enableGroup("PageOps");
assertTrue(config.isEndpointEnabled("remove-pages"));
assertTrue(config.isGroupEnabled("PageOps"));
}
@Test
@DisplayName("a functional group with one disabled endpoint is not enabled")
void functionalGroupWithDisabledEndpoint() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("remove-pages");
assertFalse(config.isGroupEnabled("PageOps"));
}
@Test
@DisplayName("disabledGroups reflects disabled groups and getDisabledGroups returns a copy")
void getDisabledGroupsReturnsCopy() {
EndpointConfiguration config = buildDefault();
config.disableGroup("PageOps");
Set<String> disabled = config.getDisabledGroups();
assertTrue(disabled.contains("PageOps"));
// mutating the returned set must not affect internal state
disabled.clear();
assertTrue(config.getDisabledGroups().contains("PageOps"));
}
@Test
@DisplayName("an unknown group with no endpoints is not enabled")
void unknownGroupNotEnabled() {
EndpointConfiguration config = buildDefault();
assertFalse(config.isGroupEnabled("NoSuchGroupXyz"));
}
}
@Nested
@DisplayName("tool group semantics")
class ToolGroupTests {
@Test
@DisplayName("a tool group is enabled until explicitly disabled")
void toolGroupEnabledUntilDisabled() {
EndpointConfiguration config = buildDefault();
assertTrue(config.isGroupEnabled("qpdf"));
config.disableGroup("qpdf");
assertFalse(config.isGroupEnabled("qpdf"));
}
@Test
@DisplayName("disabling a tool group does NOT cascade to its endpoints directly")
void toolGroupNoCascade() {
EndpointConfiguration config = buildDefault();
// repair has alternatives (qpdf, Ghostscript); disabling only qpdf keeps it enabled
config.disableGroup("qpdf");
assertTrue(config.isEndpointEnabled("repair"));
}
@Test
@DisplayName("endpoint with alternatives is disabled only when all tool groups are gone")
void allAlternativesDisabled() {
EndpointConfiguration config = buildDefault();
config.disableGroup("qpdf");
config.disableGroup("Ghostscript");
// repair's only alternatives are qpdf and Ghostscript
assertFalse(config.isEndpointEnabled("repair"));
}
@Test
@DisplayName("endpoint with a still-enabled alternative stays enabled")
void oneAlternativeRemains() {
EndpointConfiguration config = buildDefault();
// compress-pdf alternatives: qpdf, Ghostscript, Java
config.disableGroup("qpdf");
config.disableGroup("Ghostscript");
assertTrue(config.isEndpointEnabled("compress-pdf"));
config.disableGroup("Java");
assertFalse(config.isEndpointEnabled("compress-pdf"));
}
@Test
@DisplayName("single-dependency endpoint (no alternatives) disabled when its tool group is")
void singleDependencyDisabled() {
EndpointConfiguration config = buildDefault();
// pdf-to-epub depends on Calibre, no alternatives registered
assertTrue(config.isEndpointEnabled("pdf-to-epub"));
config.disableGroup("Calibre");
assertFalse(config.isEndpointEnabled("pdf-to-epub"));
}
}
@Nested
@DisplayName("addEndpointToGroup / addEndpointAlternative")
class RegistrationTests {
@Test
@DisplayName("addEndpointToGroup makes the endpoint part of the group")
void addEndpointToGroup() {
EndpointConfiguration config = buildDefault();
config.addEndpointToGroup("CustomGroup", "custom-endpoint");
Set<String> endpoints = config.getEndpointsForGroup("CustomGroup");
assertTrue(endpoints.contains("custom-endpoint"));
}
@Test
@DisplayName("disabling a custom functional group disables its added endpoint")
void customFunctionalGroupCascades() {
EndpointConfiguration config = buildDefault();
config.addEndpointToGroup("CustomGroup", "custom-endpoint");
assertTrue(config.isEndpointEnabled("custom-endpoint"));
config.disableGroup("CustomGroup");
assertFalse(config.isEndpointEnabled("custom-endpoint"));
}
@Test
@DisplayName("getEndpointsForGroup returns an empty set for unknown groups")
void unknownGroupEmptySet() {
EndpointConfiguration config = buildDefault();
Set<String> endpoints = config.getEndpointsForGroup("NoSuchGroupXyz");
assertNotNull(endpoints);
assertTrue(endpoints.isEmpty());
}
}
@Nested
@DisplayName("getEndpointAvailability / determineDisableReason")
class AvailabilityTests {
@Test
@DisplayName("an enabled endpoint has a null disable reason")
void enabledHasNullReason() {
EndpointConfiguration config = buildDefault();
EndpointAvailability availability = config.getEndpointAvailability("merge-pdfs");
assertTrue(availability.isEnabled());
assertNull(availability.getReason());
}
@Test
@DisplayName("explicit disable preserves the supplied reason")
void explicitDisableReason() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs", DisableReason.DEPENDENCY);
EndpointAvailability availability = config.getEndpointAvailability("merge-pdfs");
assertFalse(availability.isEnabled());
assertEquals(DisableReason.DEPENDENCY, availability.getReason());
}
@Test
@DisplayName("default disableEndpoint reason is CONFIG")
void defaultDisableReasonIsConfig() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs");
assertEquals(
DisableReason.CONFIG, config.getEndpointAvailability("merge-pdfs").getReason());
}
@Test
@DisplayName("endpoint disabled via functional group reports the group's reason")
void functionalGroupReason() {
EndpointConfiguration config = buildDefault();
config.disableGroup("PageOps", DisableReason.DEPENDENCY);
EndpointAvailability availability = config.getEndpointAvailability("crop");
assertFalse(availability.isEnabled());
// crop is disabled both via group cascade and group membership; reason is DEPENDENCY
assertEquals(DisableReason.DEPENDENCY, availability.getReason());
}
}
@Nested
@DisplayName("getAllEndpoints")
class GetAllEndpointsTests {
@Test
@DisplayName("aggregates endpoints across all groups")
void aggregatesAcrossGroups() {
EndpointConfiguration config = buildDefault();
Set<String> all = config.getAllEndpoints();
assertTrue(all.contains("merge-pdfs"));
assertTrue(all.contains("compress-pdf"));
assertTrue(all.contains("ocr-pdf"));
assertFalse(all.isEmpty());
}
@Test
@DisplayName("custom endpoints registered after init appear in getAllEndpoints")
void includesCustomEndpoints() {
EndpointConfiguration config = buildDefault();
config.addEndpointToGroup("CustomGroup", "brand-new-endpoint");
assertTrue(config.getAllEndpoints().contains("brand-new-endpoint"));
}
}
@Nested
@DisplayName("environment / constructor driven configuration")
class EnvironmentConfigTests {
@Test
@DisplayName("url-to-pdf is disabled when enableUrlToPDF is false (default)")
void urlToPdfDisabledByDefault() {
EndpointConfiguration config = buildDefault();
assertFalse(config.isEndpointEnabled("url-to-pdf"));
}
@Test
@DisplayName("url-to-pdf stays enabled when enableUrlToPDF is true")
void urlToPdfEnabledWhenFlagSet() {
applicationProperties.getSystem().setEnableUrlToPDF(true);
EndpointConfiguration config = build(false);
assertTrue(config.isEndpointEnabled("url-to-pdf"));
}
@Test
@DisplayName("endpoints.toRemove disables the listed endpoints at construction")
void endpointsToRemove() {
applicationProperties
.getEndpoints()
.setToRemove(List.of(" merge-pdfs ", "split-pages"));
EndpointConfiguration config = build(false);
// values are trimmed before disabling
assertFalse(config.isEndpointEnabled("merge-pdfs"));
assertFalse(config.isEndpointEnabled("split-pages"));
}
@Test
@DisplayName("endpoints.groupsToRemove disables the listed groups at construction")
void groupsToRemove() {
applicationProperties.getEndpoints().setGroupsToRemove(List.of(" PageOps "));
EndpointConfiguration config = build(false);
assertTrue(config.getDisabledGroups().contains("PageOps"));
assertFalse(config.isEndpointEnabled("remove-pages"));
}
@Test
@DisplayName("non-pro build disables the enterprise group")
void nonProDisablesEnterprise() {
EndpointConfiguration config = build(false);
assertTrue(config.getDisabledGroups().contains("enterprise"));
}
@Test
@DisplayName("pro build does not disable the enterprise group")
void proDoesNotDisableEnterprise() {
EndpointConfiguration config = build(true);
assertFalse(config.getDisabledGroups().contains("enterprise"));
}
}
@Nested
@DisplayName("getEndpointStatuses (Lombok getter) and logging summary")
class MiscTests {
@Test
@DisplayName("getEndpointStatuses reflects explicit disable state")
void endpointStatusesReflectDisable() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs");
assertEquals(Boolean.FALSE, config.getEndpointStatuses().get("merge-pdfs"));
}
@Test
@DisplayName("logDisabledEndpointsSummary runs without throwing")
void logSummaryDoesNotThrow() {
EndpointConfiguration config = buildDefault();
config.disableGroup("PageOps");
config.disableGroup("qpdf");
// purely a smoke test of the logging branch coverage
config.logDisabledEndpointsSummary();
}
@Test
@DisplayName("logDisabledEndpointsSummary runs when nothing is disabled")
void logSummaryNothingDisabled() {
applicationProperties.getSystem().setEnableUrlToPDF(true);
EndpointConfiguration config = build(true);
config.logDisabledEndpointsSummary();
}
}
}
@@ -1,153 +0,0 @@
package stirling.software.SPDF.pdf.parser;
import static org.assertj.core.api.Assertions.assertThat;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.util.List;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link LineAlignmentTableParser}, focused on the coincident-line merge logic and
* column-grid construction.
*/
class LineAlignmentTableParserTest {
private final LineAlignmentTableParser parser = new LineAlignmentTableParser();
// ── mergeCoincidentLines ─────────────────────────────────────────────────────────────────────
@Test
void mergeCoincidentLines_singleLine_unchanged() {
var lines = List.of(tokenized(rawLine(10f, 100f, "Revenue")));
assertThat(parser.mergeCoincidentLines(lines)).hasSize(1);
}
@Test
void mergeCoincidentLines_distinctYLines_unchanged() {
// Two lines at different y positions — must NOT be merged.
var lines =
List.of(
tokenized(rawLine(10f, 100f, "Revenue")),
tokenized(rawLine(10f, 115f, "Cost")));
assertThat(parser.mergeCoincidentLines(lines)).hasSize(2);
}
@Test
void mergeCoincidentLines_sameY_merged() {
// Simulates a financial-table row split by LineBuilder at the column gap:
// label fragment at x=72 → "Revenue"
// value fragment at x=350 → "1,234"
// Both have y=100. After merge they should form one TokenizedLine.
var label = rawLine(72f, 100f, "Revenue");
var value = rawLine(350f, 100f, "1,234");
var merged = parser.mergeCoincidentLines(List.of(tokenized(label), tokenized(value)));
assertThat(merged).hasSize(1);
// The merged line should contain tokens from both halves.
var tokens = merged.get(0).all();
assertThat(tokens.stream().map(t -> t.text()).toList())
.containsExactlyInAnyOrder("Revenue", "1,234");
}
@Test
void mergeCoincidentLines_sameY_mergedLineHasCorrectBounds() {
var label = rawLine(72f, 100f, "Revenue"); // 7 chars × 6pt = 42pt wide → right = 114
var value = rawLine(350f, 100f, "1,234"); // 5 chars × 6pt = 30pt wide → right = 380
var merged = parser.mergeCoincidentLines(List.of(tokenized(label), tokenized(value)));
var bounds = merged.get(0).line().bounds();
assertThat(bounds.x()).isEqualTo(72f);
assertThat(bounds.right()).isEqualTo(380f);
}
@Test
void mergeCoincidentLines_withinTolerance_merged() {
// Lines 1.5pt apart (within ROW_MERGE_TOLERANCE_PT = 2pt) should merge.
var a = rawLine(10f, 100.0f, "Alpha");
var b = rawLine(200f, 101.5f, "99");
var merged = parser.mergeCoincidentLines(List.of(tokenized(a), tokenized(b)));
assertThat(merged).hasSize(1);
}
@Test
void mergeCoincidentLines_beyondTolerance_notMerged() {
// Lines 3pt apart (beyond ROW_MERGE_TOLERANCE_PT = 2pt) should NOT merge.
var a = rawLine(10f, 100.0f, "Alpha");
var b = rawLine(200f, 103.0f, "99");
var merged = parser.mergeCoincidentLines(List.of(tokenized(a), tokenized(b)));
assertThat(merged).hasSize(2);
}
@Test
void mergeCoincidentLines_threeCoincident_allMerged() {
// Three fragments at the same y (e.g. wide financial table with two value columns).
var a = rawLine(72f, 100f, "Revenue");
var b = rawLine(300f, 100f, "1,234");
var c = rawLine(400f, 100f, "5,678");
var merged = parser.mergeCoincidentLines(List.of(tokenized(a), tokenized(b), tokenized(c)));
assertThat(merged).hasSize(1);
assertThat(merged.get(0).all()).hasSize(3);
}
@Test
void mergeCoincidentLines_coincidentPairFollowedByDistinctLine_twoGroups() {
var a = rawLine(72f, 100f, "Revenue");
var b = rawLine(350f, 100f, "1,234"); // same y as a → merges with a
var c = rawLine(10f, 115f, "Expenses"); // different y → stays separate
var merged = parser.mergeCoincidentLines(List.of(tokenized(a), tokenized(b), tokenized(c)));
assertThat(merged).hasSize(2);
}
@Test
void mergeCoincidentLines_numericAnchorStatus_correctAfterMerge() {
// After merging, the combined line should be an anchor (≥2 numeric tokens).
// "Revenue" alone → not an anchor. "1,234 567" alone → anchor.
// Merged → anchor with at least 2 numerics.
var label = rawLine(72f, 100f, "Revenue");
var values = rawLineMultiWord(350f, 100f, "1,234", 30f, "567", 30f);
var merged = parser.mergeCoincidentLines(List.of(tokenized(label), tokenized(values)));
assertThat(merged).hasSize(1);
assertThat(merged.get(0).isAnchor()).isTrue();
}
// ── helpers ──────────────────────────────────────────────────────────────────────────────────
/** Creates a RawLine with a single TextFragment of the given text at the given position. */
private static RawLine rawLine(float x, float y, String text) {
float width = text.length() * 6f; // ~6pt per char — rough but consistent
float height = 12f;
Bounds bounds = new Bounds(x, y, width, height);
TextFragment fragment =
new TextFragment("tf-test", text, bounds, y + height, 11f, "Helvetica", false);
return new RawLine("ln-test", List.of(fragment), bounds, 1);
}
/**
* Creates a RawLine with two TextFragments representing two words separated by a small gap.
* Used to simulate a values-only line with multiple numeric tokens.
*/
private static RawLine rawLineMultiWord(
float x, float y, String word1, float w1, String word2, float w2) {
float height = 12f;
Bounds b1 = new Bounds(x, y, w1, height);
Bounds b2 = new Bounds(x + w1 + 5f, y, w2, height);
TextFragment f1 = new TextFragment("tf-1", word1, b1, y + height, 11f, "Helvetica", false);
TextFragment f2 = new TextFragment("tf-2", word2, b2, y + height, 11f, "Helvetica", false);
Bounds lineBounds = new Bounds(x, y, x + w1 + 5f + w2 - x, height);
return new RawLine("ln-test", List.of(f1, f2), lineBounds, 1);
}
/** Tokenises a RawLine via the parser's own tokenise logic (package-private access). */
private LineAlignmentTableParser.TokenizedLine tokenized(RawLine line) {
return parser.tokenize(line);
}
}
@@ -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,345 @@
package stirling.software.SPDF.pdf.parser;
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.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static stirling.software.SPDF.pdf.parser.PdfModels.RawPage;
import static stirling.software.SPDF.pdf.parser.PdfModels.TableCell;
import static stirling.software.SPDF.pdf.parser.PdfModels.TableFragment;
import static stirling.software.SPDF.pdf.parser.PdfModels.TableRow;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.util.List;
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;
/**
* Unit tests for {@link TabulaTableParser}. Tables are built in-memory with PDFBox so the tests are
* deterministic and need no fixtures, network, or external processes.
*/
class TabulaTableParserGapTest {
private final TabulaTableParser parser = new TabulaTableParser();
// ── error / empty branches ───────────────────────────────────────────────
@Nested
@DisplayName("Empty and error branches")
class EmptyAndErrorBranches {
@Test
@DisplayName("page number 0 is out of Tabula's 1-based range -> empty list, no throw")
void pageNumberZeroReturnsEmpty() throws Exception {
byte[] pdf = pdfWithText(new String[] {"hello"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> result = parser.parse(doc, 0);
assertNotNull(result);
assertTrue(result.isEmpty());
}
}
@Test
@DisplayName("page number beyond the document -> empty list, exception swallowed")
void pageNumberOutOfRangeReturnsEmpty() throws Exception {
byte[] pdf = pdfWithText(new String[] {"hello"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> result = parser.parse(doc, 99);
assertNotNull(result);
assertTrue(result.isEmpty());
}
}
@Test
@DisplayName("negative page number -> empty list")
void negativePageNumberReturnsEmpty() throws Exception {
byte[] pdf = pdfWithText(new String[] {"hello"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertTrue(parser.parse(doc, -5).isEmpty());
}
}
@Test
@DisplayName("lattice mode on a page with no ruled lines -> no tables")
void latticeWithNoRulingsReturnsEmpty() throws Exception {
byte[] pdf = pdfWithText(new String[] {"just some prose", "no table here"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> result = parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
assertNotNull(result);
assertTrue(
result.isEmpty(), "borderless text must not be detected in lattice mode");
}
}
@Test
@DisplayName("blank page in lattice mode -> empty list")
void blankPageLatticeReturnsEmpty() throws Exception {
byte[] pdf = blankPdf();
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertTrue(parser.parse(doc, new RawPage(1, 0f, 0f, List.of())).isEmpty());
}
}
}
// ── stream mode (BasicExtractionAlgorithm) ───────────────────────────────
@Nested
@DisplayName("Stream mode")
class StreamMode {
@Test
@DisplayName("page with text yields at least one well-formed fragment")
void streamOnTextProducesFragment() throws Exception {
byte[] pdf =
pdfWithText(new String[] {"Name Age City", "Alice 30 Paris", "Bob 25 Rome"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments =
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
assertNotNull(fragments);
assertFalse(fragments.isEmpty(), "stream mode always builds a table from text");
assertFragmentWellFormed(fragments.get(0), 1, 0);
}
}
@Test
@DisplayName("fragment ids encode page and index")
void streamFragmentIdFormat() throws Exception {
byte[] pdf = pdfWithText(new String[] {"col1 col2", "a b"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments =
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
assertFalse(fragments.isEmpty());
assertEquals("tbl-p1-0", fragments.get(0).tableId());
assertEquals(1, fragments.get(0).pageNumber());
}
}
@Test
@DisplayName("rawRows and the parsed rows stay in lockstep")
void streamRowsMatchRawRows() throws Exception {
byte[] pdf = pdfWithText(new String[] {"x y", "1 2", "3 4"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments =
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
assertFalse(fragments.isEmpty());
TableFragment f = fragments.get(0);
assertEquals(f.rawRows().size(), f.rows().size());
}
}
}
// ── lattice mode with a real bordered grid ───────────────────────────────
@Nested
@DisplayName("Lattice mode")
class LatticeMode {
@Test
@DisplayName("bordered grid is detected and produces well-formed fragments")
void latticeDetectsBorderedTable() throws Exception {
byte[] pdf = pdfWithGrid();
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments =
parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
assertNotNull(fragments);
assertFalse(
fragments.isEmpty(), "a clean ruled grid must be detected in lattice mode");
TableFragment f = fragments.get(0);
assertFragmentWellFormed(f, 1, 0);
assertTrue(f.columnCount() >= 1, "a detected grid must have at least one column");
assertFalse(f.rawRows().isEmpty(), "a detected grid must have rows");
}
}
@Test
@DisplayName("convenience overload with page number routes to lattice mode")
void parseByPageNumberDetectsGrid() throws Exception {
byte[] pdf = pdfWithGrid();
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments = parser.parse(doc, 1);
assertNotNull(fragments);
assertFalse(fragments.isEmpty());
assertEquals(1, fragments.get(0).pageNumber());
}
}
@Test
@DisplayName("cell text is normalised (trimmed, newlines collapsed)")
void latticeCellTextIsNormalised() throws Exception {
byte[] pdf = pdfWithGrid();
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments =
parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
assertFalse(fragments.isEmpty());
for (List<String> row : fragments.get(0).rawRows()) {
for (String cell : row) {
assertNotNull(cell);
assertFalse(cell.contains("\n"), "newlines must be collapsed");
assertFalse(cell.contains("\r"), "carriage returns must be collapsed");
assertEquals(cell.trim(), cell, "cell text must be trimmed");
}
}
}
}
}
// ── contract invariants ──────────────────────────────────────────────────
@Nested
@DisplayName("Contract invariants")
class ContractInvariants {
@Test
@DisplayName("parse never returns null")
void parseNeverReturnsNull() throws Exception {
byte[] pdf = pdfWithText(new String[] {"abc"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertNotNull(parser.parse(doc, new RawPage(1, 0f, 0f, List.of())));
assertNotNull(parser.parse(doc, 1));
assertNotNull(parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of())));
}
}
@Test
@DisplayName("the document is not closed by the parser")
void documentRemainsOpenAfterParse() throws Exception {
byte[] pdf = pdfWithText(new String[] {"keep me open"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
// ObjectExtractor.close() would close the underlying COSDocument; the parser must
// not.
assertFalse(
doc.getDocument().isClosed(),
"parser must not close the caller's document");
assertEquals(1, doc.getNumberOfPages());
}
}
}
// ── helpers ──────────────────────────────────────────────────────────────
/** Asserts every field of a fragment satisfies the documented contract. */
private static void assertFragmentWellFormed(
TableFragment f, int expectedPage, int expectedIndex) {
assertNotNull(f);
assertEquals(expectedPage, f.pageNumber());
assertEquals("tbl-p" + expectedPage + "-" + expectedIndex, f.tableId());
assertNotNull(f.bounds());
assertNotNull(f.headers());
assertTrue(f.headers().isEmpty(), "headers are deferred to v2 and must be empty");
assertNotNull(f.rows());
assertNotNull(f.rawRows());
assertNotNull(f.warnings());
assertSame(null, f.continuedFromPage(), "continuedFromPage is deferred to v2");
assertTrue(f.columnCount() >= 0);
assertTrue(f.confidence() >= 0f && f.confidence() <= 1f, "confidence must be within [0,1]");
assertEquals(f.rawRows().size(), f.rows().size());
for (TableRow row : f.rows()) {
assertNotNull(row.cells());
for (TableCell cell : row.cells()) {
assertNotNull(cell.text());
assertNotNull(cell.bounds());
assertEquals(1, cell.colSpan(), "colSpan is always 1 in v1");
assertEquals(1, cell.rowSpan(), "rowSpan is always 1 in v1");
}
}
}
private static byte[] pdfWithText(String[] lines) throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.setNonStrokingColor(Color.BLACK);
float y = 720f;
for (String line : lines) {
cs.beginText();
cs.newLineAtOffset(72f, y);
cs.showText(line);
cs.endText();
y -= 20f;
}
}
return save(doc);
}
}
private static byte[] blankPdf() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
return save(doc);
}
}
/**
* Builds a small 3-row x 3-column ruled grid with text in each cell. The ruled lines make the
* table detectable by lattice mode.
*/
private static byte[] pdfWithGrid() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
float left = 100f;
float right = 400f;
float top = 700f;
float bottom = 550f;
int cols = 3;
int rows = 3;
float colStep = (right - left) / cols;
float rowStep = (top - bottom) / rows;
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setStrokingColor(Color.BLACK);
cs.setLineWidth(1f);
// vertical lines
for (int c = 0; c <= cols; c++) {
float x = left + c * colStep;
cs.moveTo(x, bottom);
cs.lineTo(x, top);
}
// horizontal lines
for (int r = 0; r <= rows; r++) {
float yLine = bottom + r * rowStep;
cs.moveTo(left, yLine);
cs.lineTo(right, yLine);
}
cs.stroke();
// cell text
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 10);
cs.setNonStrokingColor(Color.BLACK);
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
cs.beginText();
cs.newLineAtOffset(left + c * colStep + 5f, top - (r + 1) * rowStep + 6f);
cs.showText("R" + r + "C" + c);
cs.endText();
}
}
}
return save(doc);
}
}
private static byte[] save(PDDocument doc) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
@@ -3,11 +3,13 @@ package stirling.software.common.cluster.inprocess;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
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 java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
@@ -39,4 +41,46 @@ class LocalDiskFileStoreTest {
assertThrows(IllegalArgumentException.class, () -> store.resolve("a/b"));
assertThrows(IllegalArgumentException.class, () -> store.resolve("a\\b"));
}
@Test
void ownerSidecarCannotBeReadAsFileId(@TempDir Path dir) throws IOException {
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
FileStore.Stored stored =
store.store(new ByteArrayInputStream("hi".getBytes()), "f.bin", "alice");
String sidecarId = stored.fileId() + ".owner";
assertThrows(IllegalArgumentException.class, () -> store.resolve(sidecarId));
assertThrows(IllegalArgumentException.class, () -> store.retrieveBytes(sidecarId));
}
@Test
void ownerIsPersistedAndReturnedByGetOwner(@TempDir Path dir) throws IOException {
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
FileStore.Stored stored =
store.store(new ByteArrayInputStream("hi".getBytes()), "f.bin", "alice");
assertEquals("alice", store.getOwner(stored.fileId()));
}
@Test
void getOwnerReturnsNullWhenNoOwnerWasRecorded(@TempDir Path dir) throws IOException {
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
FileStore.Stored stored =
store.store(new ByteArrayInputStream("hi".getBytes()), "f.bin", null);
assertNull(store.getOwner(stored.fileId()));
}
@Test
void getOwnerReturnsNullForUnknownFileId(@TempDir Path dir) throws IOException {
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
assertNull(store.getOwner("00000000-0000-0000-0000-000000000000"));
}
@Test
void deleteRemovesOwnerSidecar(@TempDir Path dir) throws IOException {
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
FileStore.Stored stored =
store.store(new ByteArrayInputStream("hi".getBytes()), "f.bin", "alice");
assertTrue(store.delete(stored.fileId()));
assertFalse(Files.exists(dir.resolve(stored.fileId() + ".owner")));
assertNull(store.getOwner(stored.fileId()));
}
}
@@ -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,520 @@
package stirling.software.common.configuration;
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.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
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.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.CustomPaths.Operations;
import stirling.software.common.model.ApplicationProperties.CustomPaths.Pipeline;
import stirling.software.common.model.ApplicationProperties.ProcessExecutor.UnoServerEndpoint;
/**
* Unit tests for {@link RuntimePathConfig}. All of the resolution logic lives in the constructor,
* so each test builds a real {@link ApplicationProperties} (a plain @Data POJO with sensible
* defaults), constructs the config, and asserts on the exposed getters.
*/
class RuntimePathConfigTest {
/** The base path the production code derives from {@link InstallationPathConfig#getPath()}. */
private static final String BASE_PATH = InstallationPathConfig.getPath();
private static ApplicationProperties newProperties() {
return new ApplicationProperties();
}
private static RuntimePathConfig build(ApplicationProperties properties) {
return new RuntimePathConfig(properties);
}
@Nested
@DisplayName("Pipeline directory resolution")
class PipelinePaths {
@Test
@DisplayName("Defaults to <basePath>/pipeline and derived sub-folders")
void defaultPipelinePaths() {
RuntimePathConfig config = build(newProperties());
String expectedPipeline = Path.of(BASE_PATH, "pipeline").toString();
assertEquals(expectedPipeline, config.getPipelinePath());
// Watched folders are resolved to an absolute, normalized path by the production code.
assertEquals(
Path.of(expectedPipeline, "watchedFolders")
.toAbsolutePath()
.normalize()
.toString(),
config.getPipelineWatchedFoldersPath());
assertEquals(
Path.of(expectedPipeline, "finishedFolders").toString(),
config.getPipelineFinishedFoldersPath());
assertEquals(
Path.of(expectedPipeline, "defaultWebUIConfigs").toString(),
config.getPipelineDefaultWebUiConfigs());
}
@Test
@DisplayName("Custom pipelineDir overrides the default pipeline path")
void customPipelineDir() {
ApplicationProperties properties = newProperties();
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
pipeline.setPipelineDir("/custom/pipeline");
RuntimePathConfig config = build(properties);
assertEquals("/custom/pipeline", config.getPipelinePath());
// Sub-folders are derived from the (already-resolved) custom pipeline path.
assertEquals(
Path.of("/custom/pipeline", "finishedFolders").toString(),
config.getPipelineFinishedFoldersPath());
assertEquals(
Path.of("/custom/pipeline", "defaultWebUIConfigs").toString(),
config.getPipelineDefaultWebUiConfigs());
}
@Test
@DisplayName("Blank pipelineDir falls back to the default")
void blankPipelineDirFallsBackToDefault() {
ApplicationProperties properties = newProperties();
properties.getSystem().getCustomPaths().getPipeline().setPipelineDir(" ");
RuntimePathConfig config = build(properties);
assertEquals(Path.of(BASE_PATH, "pipeline").toString(), config.getPipelinePath());
}
@Test
@DisplayName("Custom finished and webUI configs dirs override defaults")
void customFinishedAndWebUiDirs() {
ApplicationProperties properties = newProperties();
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
pipeline.setFinishedFoldersDir("/custom/finished");
pipeline.setWebUIConfigsDir("/custom/webui");
RuntimePathConfig config = build(properties);
assertEquals("/custom/finished", config.getPipelineFinishedFoldersPath());
assertEquals("/custom/webui", config.getPipelineDefaultWebUiConfigs());
}
}
@Nested
@DisplayName("Watched folder resolution")
class WatchedFolders {
@Test
@DisplayName("Default watched folder is <pipeline>/watchedFolders and list has one entry")
void defaultWatchedFolder() {
RuntimePathConfig config = build(newProperties());
// Watched folders are resolved to an absolute, normalized path by the production code.
String expected =
Path.of(Path.of(BASE_PATH, "pipeline").toString(), "watchedFolders")
.toAbsolutePath()
.normalize()
.toString();
assertEquals(expected, config.getPipelineWatchedFoldersPath());
assertEquals(1, config.getPipelineWatchedFoldersPaths().size());
assertEquals(expected, config.getPipelineWatchedFoldersPaths().get(0));
}
@Test
@DisplayName("Legacy single watchedFoldersDir is used when no list is provided")
void legacyWatchedFolder() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDir("relativeWatched");
RuntimePathConfig config = build(properties);
// Legacy paths are normalized to absolute.
String expected = Path.of("relativeWatched").toAbsolutePath().normalize().toString();
assertEquals(1, config.getPipelineWatchedFoldersPaths().size());
assertEquals(expected, config.getPipelineWatchedFoldersPath());
}
@Test
@DisplayName("New list config takes precedence over the legacy single dir")
void listTakesPrecedenceOverLegacy() {
ApplicationProperties properties = newProperties();
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
pipeline.setWatchedFoldersDir("legacyDir");
pipeline.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList("listDirA", "listDirB")));
RuntimePathConfig config = build(properties);
List<String> paths = config.getPipelineWatchedFoldersPaths();
assertEquals(2, paths.size());
assertEquals(Path.of("listDirA").toAbsolutePath().normalize().toString(), paths.get(0));
assertEquals(Path.of("listDirB").toAbsolutePath().normalize().toString(), paths.get(1));
// The legacy value must NOT appear when the list is present.
assertFalse(
paths.contains(Path.of("legacyDir").toAbsolutePath().normalize().toString()));
}
@Test
@DisplayName("Duplicate paths in the list are de-duplicated after normalization")
void duplicatePathsAreDeduplicated() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDirs(
new ArrayList<>(Arrays.asList("dupDir", "dupDir", "otherDir")));
RuntimePathConfig config = build(properties);
List<String> paths = config.getPipelineWatchedFoldersPaths();
assertEquals(2, paths.size());
assertEquals(Path.of("dupDir").toAbsolutePath().normalize().toString(), paths.get(0));
assertEquals(Path.of("otherDir").toAbsolutePath().normalize().toString(), paths.get(1));
}
@Test
@DisplayName("Blank and whitespace-only list entries are sanitized out")
void blankListEntriesAreFiltered() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDirs(
new ArrayList<>(Arrays.asList(" ", "", "validDir", " ")));
RuntimePathConfig config = build(properties);
List<String> paths = config.getPipelineWatchedFoldersPaths();
assertEquals(1, paths.size());
assertEquals(Path.of("validDir").toAbsolutePath().normalize().toString(), paths.get(0));
}
@Test
@DisplayName("List entries are trimmed before resolution")
void listEntriesAreTrimmed() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList(" spacedDir ")));
RuntimePathConfig config = build(properties);
assertEquals(
Path.of("spacedDir").toAbsolutePath().normalize().toString(),
config.getPipelineWatchedFoldersPath());
}
@Test
@DisplayName("An all-blank list falls back to the legacy dir, then default")
void allBlankListFallsBackToDefault() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList("", " ")));
RuntimePathConfig config = build(properties);
// sanitizePathList strips everything -> empty -> falls through to default watched
// folder.
// The default is also resolved to an absolute, normalized path by the production code.
String expectedDefault =
Path.of(Path.of(BASE_PATH, "pipeline").toString(), "watchedFolders")
.toAbsolutePath()
.normalize()
.toString();
assertEquals(1, config.getPipelineWatchedFoldersPaths().size());
assertEquals(expectedDefault, config.getPipelineWatchedFoldersPath());
}
@Test
@DisplayName("First watched folder path is always exposed via the singular getter")
void singularGetterReturnsFirstEntry() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList("firstDir", "secondDir")));
RuntimePathConfig config = build(properties);
assertEquals(
config.getPipelineWatchedFoldersPaths().get(0),
config.getPipelineWatchedFoldersPath());
assertEquals(
Path.of("firstDir").toAbsolutePath().normalize().toString(),
config.getPipelineWatchedFoldersPath());
}
}
@Nested
@DisplayName("Operation tool path resolution")
class OperationPaths {
@Test
@DisplayName("Defaults to bare command names when not running in Docker")
void defaultOperationPaths() {
// The test host has no /.dockerenv, so the non-docker defaults apply.
RuntimePathConfig config = build(newProperties());
assertEquals("weasyprint", config.getWeasyPrintPath());
assertEquals("unoconvert", config.getUnoConvertPath());
assertEquals("ebook-convert", config.getCalibrePath());
assertEquals("ocrmypdf", config.getOcrMyPdfPath());
assertEquals("soffice", config.getSOfficePath());
}
@Test
@DisplayName("Custom operation paths override the defaults")
void customOperationPaths() {
ApplicationProperties properties = newProperties();
Operations operations = properties.getSystem().getCustomPaths().getOperations();
operations.setWeasyprint("/opt/custom/weasyprint");
operations.setUnoconvert("/opt/custom/unoconvert");
operations.setCalibre("/opt/custom/ebook-convert");
operations.setOcrmypdf("/opt/custom/ocrmypdf");
operations.setSoffice("/opt/custom/soffice");
RuntimePathConfig config = build(properties);
assertEquals("/opt/custom/weasyprint", config.getWeasyPrintPath());
assertEquals("/opt/custom/unoconvert", config.getUnoConvertPath());
assertEquals("/opt/custom/ebook-convert", config.getCalibrePath());
assertEquals("/opt/custom/ocrmypdf", config.getOcrMyPdfPath());
assertEquals("/opt/custom/soffice", config.getSOfficePath());
}
@Test
@DisplayName("Blank custom operation path falls back to the default")
void blankOperationPathFallsBack() {
ApplicationProperties properties = newProperties();
properties.getSystem().getCustomPaths().getOperations().setWeasyprint(" ");
RuntimePathConfig config = build(properties);
assertEquals("weasyprint", config.getWeasyPrintPath());
}
@Test
@DisplayName("A single custom path leaves the other operation paths at defaults")
void partialOperationOverride() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getOperations()
.setSoffice("/usr/local/soffice");
RuntimePathConfig config = build(properties);
assertEquals("/usr/local/soffice", config.getSOfficePath());
assertEquals("weasyprint", config.getWeasyPrintPath());
assertEquals("unoconvert", config.getUnoConvertPath());
}
}
@Nested
@DisplayName("Tesseract data path resolution")
class TessdataPath {
@Test
@DisplayName("Explicit tessdataDir config wins over env var and default")
void configuredTessdataDirWins() {
ApplicationProperties properties = newProperties();
properties.getSystem().setTessdataDir("/my/tessdata");
RuntimePathConfig config = build(properties);
// Config setting has the highest priority regardless of TESSDATA_PREFIX env state.
assertEquals("/my/tessdata", config.getTessDataPath());
}
@Test
@DisplayName("tessDataPath is never null even with no config")
void tessDataPathNeverNull() {
RuntimePathConfig config = build(newProperties());
// With no config setting, the value comes from TESSDATA_PREFIX or the hard default,
// either of which is non-null.
assertNotNull(config.getTessDataPath());
assertFalse(config.getTessDataPath().isEmpty());
}
}
@Nested
@DisplayName("UNO server endpoint resolution")
class UnoServerEndpoints {
@Test
@DisplayName("Auto mode builds one endpoint when session limit is unset (defaults to 1)")
void autoSingleEndpointByDefault() {
// Default ApplicationProperties: autoUnoServer = true, libreOfficeSessionLimit = 0 ->
// 1.
RuntimePathConfig config = build(newProperties());
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(1, endpoints.size());
assertEquals("127.0.0.1", endpoints.get(0).getHost());
assertEquals(2003, endpoints.get(0).getPort());
}
@Test
@DisplayName("Auto mode builds N endpoints on consecutive even ports")
void autoMultipleEndpoints() {
ApplicationProperties properties = newProperties();
properties.getProcessExecutor().getSessionLimit().setLibreOfficeSessionLimit(3);
RuntimePathConfig config = build(properties);
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(3, endpoints.size());
assertEquals(2003, endpoints.get(0).getPort());
assertEquals(2005, endpoints.get(1).getPort());
assertEquals(2007, endpoints.get(2).getPort());
for (UnoServerEndpoint endpoint : endpoints) {
assertEquals("127.0.0.1", endpoint.getHost());
}
}
@Test
@DisplayName("Manual mode returns the configured (valid) endpoints")
void manualEndpointsAreUsed() {
ApplicationProperties properties = newProperties();
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
processExecutor.setAutoUnoServer(false);
UnoServerEndpoint endpoint = new UnoServerEndpoint();
endpoint.setHost("10.0.0.5");
endpoint.setPort(4000);
processExecutor.setUnoServerEndpoints(new ArrayList<>(Arrays.asList(endpoint)));
RuntimePathConfig config = build(properties);
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(1, endpoints.size());
assertEquals("10.0.0.5", endpoints.get(0).getHost());
assertEquals(4000, endpoints.get(0).getPort());
}
@Test
@DisplayName("Manual mode filters out endpoints with blank host or non-positive port")
void manualEndpointsAreSanitized() {
ApplicationProperties properties = newProperties();
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
processExecutor.setAutoUnoServer(false);
UnoServerEndpoint valid = new UnoServerEndpoint();
valid.setHost("192.168.1.10");
valid.setPort(5000);
UnoServerEndpoint blankHost = new UnoServerEndpoint();
blankHost.setHost(" ");
blankHost.setPort(5001);
UnoServerEndpoint badPort = new UnoServerEndpoint();
badPort.setHost("192.168.1.11");
badPort.setPort(0);
processExecutor.setUnoServerEndpoints(
new ArrayList<>(Arrays.asList(valid, blankHost, badPort)));
RuntimePathConfig config = build(properties);
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(1, endpoints.size());
assertEquals("192.168.1.10", endpoints.get(0).getHost());
assertEquals(5000, endpoints.get(0).getPort());
}
@Test
@DisplayName("Manual mode with no usable endpoints falls back to a single default endpoint")
void manualModeNoEndpointsFallsBackToDefault() {
ApplicationProperties properties = newProperties();
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
processExecutor.setAutoUnoServer(false);
processExecutor.setUnoServerEndpoints(new ArrayList<>());
RuntimePathConfig config = build(properties);
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(1, endpoints.size());
assertEquals("127.0.0.1", endpoints.get(0).getHost());
assertEquals(2003, endpoints.get(0).getPort());
}
@Test
@DisplayName("Null processExecutor defaults to a single UNO endpoint")
void nullProcessExecutorDefaultsToSingleEndpoint() {
ApplicationProperties properties = newProperties();
properties.setProcessExecutor(null);
RuntimePathConfig config = build(properties);
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(1, endpoints.size());
assertEquals("127.0.0.1", endpoints.get(0).getHost());
assertEquals(2003, endpoints.get(0).getPort());
}
}
@Nested
@DisplayName("General contract")
class GeneralContract {
@Test
@DisplayName("getProperties returns the same instance passed to the constructor")
void propertiesAccessorReturnsSameInstance() {
ApplicationProperties properties = newProperties();
RuntimePathConfig config = build(properties);
assertSame(properties, config.getProperties());
}
@Test
@DisplayName("basePath matches InstallationPathConfig.getPath()")
void basePathMatchesInstallationPath() {
RuntimePathConfig config = build(newProperties());
assertEquals(BASE_PATH, config.getBasePath());
}
@Test
@DisplayName("All resolved path getters are non-null")
void allPathsNonNull() {
RuntimePathConfig config = build(newProperties());
assertNotNull(config.getPipelinePath());
assertNotNull(config.getPipelineWatchedFoldersPath());
assertNotNull(config.getPipelineWatchedFoldersPaths());
assertNotNull(config.getPipelineFinishedFoldersPath());
assertNotNull(config.getPipelineDefaultWebUiConfigs());
assertNotNull(config.getWeasyPrintPath());
assertNotNull(config.getUnoConvertPath());
assertNotNull(config.getCalibrePath());
assertNotNull(config.getOcrMyPdfPath());
assertNotNull(config.getSOfficePath());
assertNotNull(config.getTessDataPath());
assertNotNull(config.getUnoServerEndpoints());
assertTrue(config.getUnoServerEndpoints().size() >= 1);
}
}
}
@@ -2,7 +2,7 @@ package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Paths;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
@@ -31,18 +31,33 @@ class ApplicationPropertiesLogicTest {
assertTrue(sys.isAnalyticsEnabled());
}
@Test
void storageSigning_userListScope_defaultsToOrg_andIsSettable() {
// Self-host backward-compat: scope must default to "org" (saas profile pins "team").
ApplicationProperties.Storage.Signing signing = new ApplicationProperties.Storage.Signing();
assertFalse(signing.isEnabled());
assertEquals("org", signing.getUserListScope());
signing.setUserListScope("team");
assertEquals("team", signing.getUserListScope());
// Reachable from the full tree as storage.signing.userListScope.
assertEquals(
"org", new ApplicationProperties().getStorage().getSigning().getUserListScope());
}
@Test
void tempFileManagement_defaults_and_overrides() {
Function<String, String> normalize = s -> Paths.get(s).normalize().toString();
Function<String, String> normalize = s -> Path.of(s).normalize().toString();
ApplicationProperties.TempFileManagement tfm =
new ApplicationProperties.TempFileManagement();
String expectedBase =
Paths.get(java.lang.System.getProperty("java.io.tmpdir"), "stirling-pdf")
.toString();
Path.of(java.lang.System.getProperty("java.io.tmpdir"), "stirling-pdf").toString();
assertEquals(expectedBase, tfm.getBaseTmpDir());
String expectedLibre = Paths.get(expectedBase, "libreoffice").toString();
String expectedLibre = Path.of(expectedBase, "libreoffice").toString();
assertEquals(expectedLibre, tfm.getLibreofficeDir());
tfm.setBaseTmpDir("/custom/base");
@@ -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,269 @@
package stirling.software.common.pdf;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
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.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.Disabled;
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.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.jpdfium.text.TextLine;
import stirling.software.jpdfium.text.TextWord;
/**
* Accuracy and robustness tests for {@link PdfMarkdownConverter}, comparing conversion output
* against hand-authored golden Markdown for a set of owned/synthetic fixtures.
*
* <p>The {@link #gatedFixtures()} set is enforced in CI: those fixtures currently convert within
* the accuracy threshold and guard against regressions. Fixtures still being iterated on live in
* {@link #wipFixtures()} under a {@link Disabled} test so the goldens stay in the tree without
* breaking the build. Enable the WIP test locally to see per-fixture scores while working on the
* converter.
*/
class PdfMarkdownConverterTest {
/** Accuracy threshold: output must share at least this fraction of content with the golden. */
private static final double THRESHOLD = 0.95;
@TempDir Path tmp;
/** Fixtures that meet the accuracy threshold today and therefore gate CI. */
static Stream<Arguments> gatedFixtures() {
return Stream.of(
Arguments.of("multi-column-test_lorem.pdf", "multi-column-test_lorem.md"),
Arguments.of("bordered-table-test_widget.pdf", "bordered-table-test_widget.md"),
Arguments.of("many-tables-test_stress.pdf", "many-tables-test_stress.md"));
}
/** Fixtures still below the threshold; tracked here, enable locally to iterate. */
static Stream<Arguments> wipFixtures() {
return Stream.of(
Arguments.of(
"wrapped-cell-test_expense-report.pdf",
"wrapped-cell-test_expense-report.md"));
}
@ParameterizedTest(name = "{0}")
@MethodSource("gatedFixtures")
void convertMatchesGoldenMarkdown(String pdfName, String mdName) throws IOException {
assertConversionMatchesGolden(pdfName, mdName);
}
@Disabled("WIP fixtures below the accuracy threshold; enable locally to iterate")
@ParameterizedTest(name = "{0}")
@MethodSource("wipFixtures")
void convertMatchesGoldenMarkdownWip(String pdfName, String mdName) throws IOException {
assertConversionMatchesGolden(pdfName, mdName);
}
/**
* Degenerate/extreme geometry must not crash the converter. A crafted or malformed PDF can
* position text anywhere via a text matrix, so a row's words can span from near the origin to a
* coordinate beyond {@link Integer#MAX_VALUE}. The old column-detection code sized an {@code
* int[]} straight from {@code (int) Math.ceil(maxX) - lo}, which either allocated a multi-GB
* array (OutOfMemoryError) or overflowed to a negative length (NegativeArraySizeException) —
* taking down the request thread. Detection must instead bail out and return no columns.
*/
@Test
void columnDetectionSurvivesDegenerateGeometry() {
// x ≈ 2.5e9 is past Integer.MAX_VALUE; combined with a near-origin word it yields an
// implausible span that the pre-fix code turned into a fatal array allocation.
List<TextLine> rows = new ArrayList<>();
for (int r = 0; r < 4; r++) {
float y = 400f - r * 12f;
TextWord near = new TextWord(List.of(), 50f, y, 30f, 10f);
TextWord far = new TextWord(List.of(), 2_500_000_000f, y, 30f, 10f);
rows.add(new TextLine(List.of(near, far), 50f, y, 2_499_999_980f, 10f));
}
List<float[]> columns =
assertDoesNotThrow(() -> PdfMarkdownConverter.findColumnRangesFromLines(rows));
assertTrue(
columns.isEmpty(),
"implausible page span should disable column detection, not allocate from it");
}
private void assertConversionMatchesGolden(String pdfName, String mdName) throws IOException {
Path pdfPath = tmp.resolve(pdfName);
try (InputStream in =
getClass().getResourceAsStream("/pdf-ingestion-fixtures/" + pdfName)) {
if (in == null) {
fail("Fixture not found on classpath: /pdf-ingestion-fixtures/" + pdfName);
}
Files.copy(in, pdfPath);
}
String actual;
try (PdfDocument doc = PdfDocument.open(pdfPath)) {
actual = new PdfMarkdownConverter().convert(doc);
}
String expected;
try (InputStream in = getClass().getResourceAsStream("/pdf-ingestion-fixtures/" + mdName)) {
if (in == null) {
fail("Golden file not found on classpath: /pdf-ingestion-fixtures/" + mdName);
}
expected = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
// Image placeholders are not scored: their body text is a TODO ("ideally, add the info
// available about the image...") rather than real content, so comparing it would penalise
// output for matching a placeholder we intend to replace. Drop those lines from both sides.
expected = stripImagePlaceholders(expected);
actual = stripImagePlaceholders(actual);
double similarity = similarity(expected, actual);
if (similarity < THRESHOLD) {
fail(
String.format(
"Markdown output differs from golden file '%s' by %.1f%% (threshold %.0f%%):%n%s",
mdName,
(1.0 - similarity) * 100,
(1.0 - THRESHOLD) * 100,
unifiedDiff(expected, actual)));
}
}
/** Substring identifying an image-placeholder line, which is excluded from scoring. */
private static final String IMAGE_PLACEHOLDER_MARKER = "Image intentionally redacted";
/**
* Removes non-content lines from the comparison: image placeholders (TODO text we intend to
* replace) and GFM table separator rows (the {@code |---|---|} divider, whose exact dash count
* is cosmetic — any run of three or more dashes is valid Markdown).
*/
private static String stripImagePlaceholders(String md) {
StringBuilder sb = new StringBuilder();
for (String line : md.split("\n", -1)) {
if (line.contains(IMAGE_PLACEHOLDER_MARKER)
|| line.strip().startsWith("<image redacted")
|| isTableSeparatorRow(line)) {
continue;
}
if (sb.length() > 0) {
sb.append('\n');
}
sb.append(line);
}
return sb.toString();
}
/** True for a GFM table separator row, e.g. {@code |---|:--:|---|} (only |, -, :, space). */
private static boolean isTableSeparatorRow(String line) {
String t = line.strip();
if (!t.contains("-")) {
return false;
}
return t.chars().allMatch(c -> c == '|' || c == '-' || c == ':' || c == ' ');
}
/**
* Character-level similarity: proportion of expected characters that appear in the LCS. O(n*m)
* but golden files are small enough that this is fine.
*/
private static double similarity(String expected, String actual) {
if (expected.isEmpty() && actual.isEmpty()) return 1.0;
if (expected.isEmpty() || actual.isEmpty()) return 0.0;
// Strip all whitespace for a content-focused comparison
String e = expected.replaceAll("\\s+", " ").strip();
String a = actual.replaceAll("\\s+", " ").strip();
int lcs = lcsLength(e, a);
return (double) lcs / Math.max(e.length(), a.length());
}
private static int lcsLength(String a, String b) {
// Use two-row DP to keep memory reasonable
int m = a.length(), n = b.length();
int[] prev = new int[n + 1];
int[] curr = new int[n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (a.charAt(i - 1) == b.charAt(j - 1)) {
curr[j] = prev[j - 1] + 1;
} else {
curr[j] = Math.max(curr[j - 1], prev[j]);
}
}
int[] tmp = prev;
prev = curr;
curr = tmp;
java.util.Arrays.fill(curr, 0);
}
return prev[n];
}
private static String unifiedDiff(String expected, String actual) {
String[] expectedLines = expected.split("\n", -1);
String[] actualLines = actual.split("\n", -1);
List<String> diff = new ArrayList<>();
diff.add("--- expected");
diff.add("+++ actual");
int maxLines = Math.max(expectedLines.length, actualLines.length);
int context = 3;
boolean inHunk = false;
int hunkStart = -1;
List<String> hunkLines = new ArrayList<>();
for (int i = 0; i < maxLines; i++) {
String exp = i < expectedLines.length ? expectedLines[i] : null;
String act = i < actualLines.length ? actualLines[i] : null;
boolean changed = exp == null || act == null || !exp.equals(act);
if (changed) {
if (!inHunk) {
inHunk = true;
hunkStart = Math.max(0, i - context);
// add context lines before change
for (int c = hunkStart; c < i; c++) {
hunkLines.add(" " + (c < expectedLines.length ? expectedLines[c] : ""));
}
}
if (exp != null) hunkLines.add("-" + exp);
if (act != null) hunkLines.add("+" + act);
} else {
if (inHunk) {
hunkLines.add(" " + exp);
// check if we're far enough past the last change to close the hunk
boolean moreChanges = false;
for (int j = i + 1; j < Math.min(i + context, maxLines); j++) {
String e2 = j < expectedLines.length ? expectedLines[j] : null;
String a2 = j < actualLines.length ? actualLines[j] : null;
if (e2 == null || a2 == null || !e2.equals(a2)) {
moreChanges = true;
break;
}
}
if (!moreChanges && (i - hunkStart) >= context) {
diff.add("@@ -" + (hunkStart + 1) + " @@");
diff.addAll(hunkLines);
hunkLines.clear();
inHunk = false;
}
}
}
}
if (inHunk && !hunkLines.isEmpty()) {
diff.add("@@ -" + (hunkStart + 1) + " @@");
diff.addAll(hunkLines);
}
return String.join("\n", diff);
}
}
@@ -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();
}
}
}
@@ -5,6 +5,7 @@ import static org.mockito.Mockito.mock;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -19,7 +20,8 @@ class FileStorageDelegationTest {
FileStorage fs =
new FileStorage(
mock(FileOrUploadService.class),
new LocalDiskFileStore(tempDir.toString()));
new LocalDiskFileStore(tempDir.toString()),
Optional.empty());
byte[] payload = "round-trip".getBytes();
String id = fs.storeBytes(payload, "x.bin");
assertArrayEquals(payload, fs.retrieveBytes(id));
@@ -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,107 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import stirling.software.common.cluster.inprocess.LocalDiskFileStore;
import stirling.software.common.util.JobContext;
class FileStorageOwnershipTest {
private FileStorage newStorageWithoutSecurity(Path tempDir) {
return new FileStorage(
mock(FileOrUploadService.class),
new LocalDiskFileStore(tempDir.toString()),
Optional.empty());
}
private FileStorage newStorageWithCurrentUser(Path tempDir, AtomicReference<String> userRef) {
JobOwnershipService svc = mock(JobOwnershipService.class);
when(svc.getCurrentUserId()).thenAnswer(invocation -> Optional.ofNullable(userRef.get()));
return new FileStorage(
mock(FileOrUploadService.class),
new LocalDiskFileStore(tempDir.toString()),
Optional.of(svc));
}
@Test
void desktopMode_noOwnershipService_storesAndRetrievesWithoutChecks(@TempDir Path tempDir)
throws IOException {
FileStorage fs = newStorageWithoutSecurity(tempDir);
byte[] payload = "desktop".getBytes();
String id = fs.storeBytes(payload, "x.bin");
assertArrayEquals(payload, fs.retrieveBytes(id));
}
@Test
void sameUserStoresAndRetrieves_allowed(@TempDir Path tempDir) throws IOException {
AtomicReference<String> user = new AtomicReference<>("alice");
FileStorage fs = newStorageWithCurrentUser(tempDir, user);
byte[] payload = "alice's file".getBytes();
String id = fs.storeBytes(payload, "x.bin");
assertArrayEquals(payload, fs.retrieveBytes(id));
}
@Test
void differentUserRetrieves_throwsSecurityException(@TempDir Path tempDir) throws IOException {
AtomicReference<String> user = new AtomicReference<>("alice");
FileStorage fs = newStorageWithCurrentUser(tempDir, user);
String id = fs.storeBytes("alice's file".getBytes(), "x.bin");
user.set("bob");
assertThrows(SecurityException.class, () -> fs.retrieveBytes(id));
assertThrows(SecurityException.class, () -> fs.retrieveInputStream(id));
assertThrows(SecurityException.class, () -> fs.getFileSize(id));
assertThrows(SecurityException.class, () -> fs.fileExists(id));
assertThrows(SecurityException.class, () -> fs.deleteFile(id));
}
@Test
void anonymousRetrieveOfOwnedFile_allowed_noCurrentUserMeansNoCompare(@TempDir Path tempDir)
throws IOException {
AtomicReference<String> user = new AtomicReference<>("alice");
FileStorage fs = newStorageWithCurrentUser(tempDir, user);
byte[] payload = "alice's file".getBytes();
String id = fs.storeBytes(payload, "x.bin");
user.set(null);
assertArrayEquals(payload, fs.retrieveBytes(id));
}
@Test
void authedRetrieveOfAnonymousFile_allowed_noOwnerOnFile(@TempDir Path tempDir)
throws IOException {
AtomicReference<String> user = new AtomicReference<>(null);
FileStorage fs = newStorageWithCurrentUser(tempDir, user);
byte[] payload = "no-owner".getBytes();
String id = fs.storeBytes(payload, "x.bin");
user.set("alice");
assertArrayEquals(payload, fs.retrieveBytes(id));
}
@Test
void propagatedOwner_scopesAsyncWriteWithNoLiveUser(@TempDir Path tempDir) throws IOException {
AtomicReference<String> user = new AtomicReference<>(null);
FileStorage fs = newStorageWithCurrentUser(tempDir, user);
byte[] payload = "alice's async result".getBytes();
String id;
try {
JobContext.setOwner("alice");
id = fs.storeBytes(payload, "x.bin");
} finally {
JobContext.clear();
}
user.set("alice");
assertArrayEquals(payload, fs.retrieveBytes(id));
user.set("bob");
assertThrows(SecurityException.class, () -> fs.retrieveBytes(id));
}
}
@@ -9,6 +9,8 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
@@ -37,7 +39,10 @@ class FileStorageTest {
void setUp() throws IOException {
MockitoAnnotations.openMocks(this);
fileStorage =
new FileStorage(fileOrUploadService, new LocalDiskFileStore(tempDir.toString()));
new FileStorage(
fileOrUploadService,
new LocalDiskFileStore(tempDir.toString()),
Optional.empty());
// Create a mock MultipartFile
mockFile = mock(MultipartFile.class);
@@ -79,7 +84,7 @@ class FileStorageTest {
void testRetrieveFile() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = "test-file-1";
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
@@ -99,7 +104,7 @@ class FileStorageTest {
void testRetrieveBytes() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = "test-file-2";
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
@@ -113,7 +118,7 @@ class FileStorageTest {
@Test
void testRetrieveFile_FileNotFound() {
// Arrange
String nonExistentFileId = "non-existent-file";
String nonExistentFileId = UUID.randomUUID().toString();
// Act & Assert
assertThrows(IOException.class, () -> fileStorage.retrieveFile(nonExistentFileId));
@@ -122,7 +127,7 @@ class FileStorageTest {
@Test
void testRetrieveBytes_FileNotFound() {
// Arrange
String nonExistentFileId = "non-existent-file";
String nonExistentFileId = UUID.randomUUID().toString();
// Act & Assert
assertThrows(IOException.class, () -> fileStorage.retrieveBytes(nonExistentFileId));
@@ -132,7 +137,7 @@ class FileStorageTest {
void testDeleteFile() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = "test-file-3";
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
@@ -147,7 +152,7 @@ class FileStorageTest {
@Test
void testDeleteFile_FileNotFound() {
// Arrange
String nonExistentFileId = "non-existent-file";
String nonExistentFileId = UUID.randomUUID().toString();
// Act
boolean result = fileStorage.deleteFile(nonExistentFileId);
@@ -160,7 +165,7 @@ class FileStorageTest {
void testFileExists() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = "test-file-4";
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
@@ -174,7 +179,7 @@ class FileStorageTest {
@Test
void testFileExists_FileNotFound() {
// Arrange
String nonExistentFileId = "non-existent-file";
String nonExistentFileId = UUID.randomUUID().toString();
// Act
boolean result = fileStorage.fileExists(nonExistentFileId);
@@ -59,6 +59,53 @@ class InternalApiClientTest {
servletContext, userService, tempFileManager, environment, applicationProperties);
}
@Test
void postTagsRequestAsAutomation() throws Exception {
// Every InternalApiClient.post() caller is a parent automation flow dispatching a child
// tool (pipeline executor, AI workflow, policy runner). Tagging the sub-step here means
// the saas PaygChargeInterceptor classifies it as BillingCategory.AUTOMATION regardless of
// the dispatched controller's @RequiresFeature — so an AI-OCR step inside a policy run
// bills as AUTOMATION, not AI. The header value is the literal string "true" because the
// interceptor compares case-insensitively-trimmed against that token.
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("fileInput", namedResource("input.pdf", "data"));
Path tempPath = Files.createTempFile("internal-api-automation-test", ".tmp");
TempFile tempFile = mock(TempFile.class);
when(tempFile.getPath()).thenReturn(tempPath);
when(tempFile.getFile()).thenReturn(tempPath.toFile());
when(tempFileManager.createManagedTempFile("internal-api")).thenReturn(tempFile);
HttpHeaders[] captured = {null};
try (var ignored =
mockConstruction(
RestTemplate.class,
(rt, ctx) -> {
when(rt.httpEntityCallback(any(), eq(Resource.class)))
.thenAnswer(
inv -> {
HttpEntity<?> entity = inv.getArgument(0);
captured[0] = entity.getHeaders();
return (RequestCallback) req -> {};
});
when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any()))
.thenAnswer(inv -> fakeOkResponse(inv.getArgument(3)));
})) {
InternalApiClient mockedClient = newClient();
mockedClient.post("/api/v1/general/merge-pdfs", body);
assertNotNull(captured[0]);
assertEquals(
"true",
captured[0].getFirst(InternalApiClient.AUTOMATION_HEADER),
"Sub-step dispatch must carry the automation marker header");
} finally {
Files.deleteIfExists(tempPath);
}
}
@Test
void postDoesNotForceContentType() throws Exception {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
@@ -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,471 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
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.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.service.MobileScannerService.FileMetadata;
import stirling.software.common.service.MobileScannerService.SessionInfo;
/**
* Unit tests for {@link MobileScannerService}. The service stores uploaded files in a temp
* directory. To keep tests isolated and deterministic, the {@code tempDirectory} field is
* redirected to a JUnit {@link TempDir} via reflection after construction.
*/
class MobileScannerServiceTest {
@TempDir Path tempDir;
private MobileScannerService service;
@BeforeEach
void setUp() throws IOException {
service = new MobileScannerService();
// Redirect the service's temp directory to the isolated test temp dir.
ReflectionTestUtils.setField(service, "tempDirectory", tempDir);
}
private MultipartFile file(String name, String content) {
return new MockMultipartFile(
"file", name, "text/plain", content.getBytes(StandardCharsets.UTF_8));
}
private MultipartFile emptyFile(String name) {
return new MockMultipartFile("file", name, "text/plain", new byte[0]);
}
@Nested
@DisplayName("createSession")
class CreateSession {
@Test
@DisplayName("creates a session and returns coherent SessionInfo")
void createsSession() {
SessionInfo info = service.createSession("abc-123");
assertNotNull(info);
assertEquals("abc-123", info.getSessionId());
assertTrue(info.getCreatedAt() > 0);
assertEquals(10 * 60 * 1000L, info.getTimeoutMs());
assertEquals(info.getCreatedAt() + info.getTimeoutMs(), info.getExpiresAt());
}
@Test
@DisplayName("session is retrievable via validateSession after creation")
void createdSessionIsValid() {
service.createSession("sess1");
assertNotNull(service.validateSession("sess1"));
}
@Test
@DisplayName("rejects null session ID")
void rejectsNull() {
assertThrows(IllegalArgumentException.class, () -> service.createSession(null));
}
@Test
@DisplayName("rejects blank session ID")
void rejectsBlank() {
assertThrows(IllegalArgumentException.class, () -> service.createSession(" "));
}
@Test
@DisplayName("rejects session ID with invalid characters")
void rejectsInvalidChars() {
assertThrows(IllegalArgumentException.class, () -> service.createSession("bad/id"));
assertThrows(IllegalArgumentException.class, () -> service.createSession("bad id"));
assertThrows(IllegalArgumentException.class, () -> service.createSession("bad_id"));
}
@Test
@DisplayName("accepts alphanumeric and hyphen session IDs")
void acceptsValidChars() {
assertNotNull(service.createSession("ABC-def-123"));
}
}
@Nested
@DisplayName("validateSession")
class ValidateSession {
@Test
@DisplayName("returns null for unknown session")
void unknownReturnsNull() {
assertNull(service.validateSession("does-not-exist"));
}
@Test
@DisplayName("returns SessionInfo for an existing session")
void existingReturnsInfo() {
service.createSession("s1");
SessionInfo info = service.validateSession("s1");
assertNotNull(info);
assertEquals("s1", info.getSessionId());
assertEquals(10 * 60 * 1000L, info.getTimeoutMs());
}
@Test
@DisplayName("expires and removes a session whose last access is in the past")
void expiredSessionRemoved() {
service.createSession("expired");
// Force the underlying session's last access far into the past.
forceLastAccess("expired", System.currentTimeMillis() - (20 * 60 * 1000L));
assertNull(service.validateSession("expired"));
// After expiry the session should be gone entirely.
assertNull(service.validateSession("expired"));
}
}
@Nested
@DisplayName("uploadFiles")
class UploadFiles {
@Test
@DisplayName("stores files and records metadata")
void storesFiles() throws IOException {
service.createSession("up1");
service.uploadFiles("up1", List.of(file("scan.txt", "hello")));
List<FileMetadata> metas = service.getSessionFiles("up1");
assertEquals(1, metas.size());
FileMetadata meta = metas.get(0);
assertEquals("scan.txt", meta.getFilename());
assertEquals(5, meta.getSize());
assertEquals("text/plain", meta.getContentType());
// File physically exists on disk.
Path stored = tempDir.resolve("up1").resolve("scan.txt");
assertTrue(Files.exists(stored));
assertEquals("hello", Files.readString(stored));
}
@Test
@DisplayName("auto-creates a session when uploading to an unregistered session ID")
void autoCreatesSession() throws IOException {
service.uploadFiles("new-session", List.of(file("a.txt", "data")));
List<FileMetadata> metas = service.getSessionFiles("new-session");
assertEquals(1, metas.size());
}
@Test
@DisplayName("skips empty files")
void skipsEmptyFiles() throws IOException {
service.createSession("up2");
service.uploadFiles("up2", List.of(emptyFile("empty.txt"), file("real.txt", "x")));
List<FileMetadata> metas = service.getSessionFiles("up2");
assertEquals(1, metas.size());
assertEquals("real.txt", metas.get(0).getFilename());
}
@Test
@DisplayName("sanitizes dangerous filename characters")
void sanitizesFilename() throws IOException {
service.createSession("up3");
service.uploadFiles("up3", List.of(file("we ird@na#me.txt", "x")));
List<FileMetadata> metas = service.getSessionFiles("up3");
assertEquals(1, metas.size());
String stored = metas.get(0).getFilename();
// Disallowed chars replaced with underscores; allowed set is [a-zA-Z0-9._-].
assertTrue(stored.matches("[a-zA-Z0-9._-]+"), "unexpected filename: " + stored);
assertTrue(Files.exists(tempDir.resolve("up3").resolve(stored)));
}
@Test
@DisplayName("handles duplicate filenames by appending a counter")
void handlesDuplicateFilenames() throws IOException {
service.createSession("up4");
service.uploadFiles("up4", List.of(file("dup.txt", "one")));
service.uploadFiles("up4", List.of(file("dup.txt", "two")));
List<FileMetadata> metas = service.getSessionFiles("up4");
assertEquals(2, metas.size());
Path original = tempDir.resolve("up4").resolve("dup.txt");
Path renamed = tempDir.resolve("up4").resolve("dup-1.txt");
assertTrue(Files.exists(original));
assertTrue(Files.exists(renamed));
assertEquals("one", Files.readString(original));
assertEquals("two", Files.readString(renamed));
}
@Test
@DisplayName("falls back to a generated name when original filename is null")
void generatesNameWhenNull() throws IOException {
service.createSession("up5");
MultipartFile noName =
new MockMultipartFile("file", null, "text/plain", "x".getBytes());
service.uploadFiles("up5", List.of(noName));
List<FileMetadata> metas = service.getSessionFiles("up5");
assertEquals(1, metas.size());
assertTrue(metas.get(0).getFilename().startsWith("upload-"));
}
@Test
@DisplayName("rejects invalid session ID before any storage")
void rejectsInvalidSessionId() {
assertThrows(
IllegalArgumentException.class,
() -> service.uploadFiles("bad/id", List.of(file("a.txt", "x"))));
}
@Test
@DisplayName("uploading an empty list leaves no files")
void emptyListNoFiles() throws IOException {
service.createSession("up6");
service.uploadFiles("up6", List.of());
assertTrue(service.getSessionFiles("up6").isEmpty());
}
}
@Nested
@DisplayName("getSessionFiles")
class GetSessionFiles {
@Test
@DisplayName("returns empty list for unknown session")
void unknownReturnsEmpty() {
assertTrue(service.getSessionFiles("nope").isEmpty());
}
@Test
@DisplayName("returns a defensive copy of the metadata list")
void returnsDefensiveCopy() throws IOException {
service.createSession("g1");
service.uploadFiles("g1", List.of(file("a.txt", "x")));
List<FileMetadata> first = service.getSessionFiles("g1");
first.clear();
// Mutating the returned list must not affect the service's internal state.
assertEquals(1, service.getSessionFiles("g1").size());
}
}
@Nested
@DisplayName("getFile")
class GetFile {
@Test
@DisplayName("returns the path of an uploaded file")
void returnsPath() throws IOException {
service.createSession("f1");
service.uploadFiles("f1", List.of(file("doc.txt", "body")));
Path path = service.getFile("f1", "doc.txt");
assertTrue(Files.exists(path));
assertEquals("body", Files.readString(path));
}
@Test
@DisplayName("throws when the session does not exist")
void unknownSessionThrows() {
IOException ex =
assertThrows(IOException.class, () -> service.getFile("ghost", "doc.txt"));
assertTrue(ex.getMessage().contains("Session not found"));
}
@Test
@DisplayName("throws when the file does not exist in an existing session")
void unknownFileThrows() throws IOException {
service.createSession("f2");
service.uploadFiles("f2", List.of(file("present.txt", "x")));
IOException ex =
assertThrows(IOException.class, () -> service.getFile("f2", "missing.txt"));
assertTrue(ex.getMessage().contains("File not found"));
}
@Test
@DisplayName("rejects filenames containing path separators")
void rejectsPathSeparators() throws IOException {
service.createSession("f3");
service.uploadFiles("f3", List.of(file("ok.txt", "x")));
assertThrows(IOException.class, () -> service.getFile("f3", "../escape.txt"));
assertThrows(IOException.class, () -> service.getFile("f3", "sub/file.txt"));
assertThrows(IOException.class, () -> service.getFile("f3", "sub\\file.txt"));
}
@Test
@DisplayName("rejects blank filename")
void rejectsBlankFilename() throws IOException {
service.createSession("f4");
service.uploadFiles("f4", List.of(file("ok.txt", "x")));
assertThrows(IOException.class, () -> service.getFile("f4", " "));
}
}
@Nested
@DisplayName("deleteFileAfterDownload")
class DeleteFileAfterDownload {
@Test
@DisplayName("deletes a single file but keeps the session if others remain")
void deletesOneFile() throws IOException {
service.createSession("d1");
service.uploadFiles("d1", List.of(file("a.txt", "x"), file("b.txt", "y")));
service.deleteFileAfterDownload("d1", "a.txt");
assertFalse(Files.exists(tempDir.resolve("d1").resolve("a.txt")));
// Session still present because not all files have been downloaded.
assertNotNull(service.validateSession("d1"));
}
@Test
@DisplayName("deletes the entire session once all files are marked downloaded")
void deletesSessionWhenAllDownloaded() throws IOException {
service.createSession("d2");
service.uploadFiles("d2", List.of(file("only.txt", "x")));
// Mark the file as downloaded via getFile, then delete it.
service.getFile("d2", "only.txt");
service.deleteFileAfterDownload("d2", "only.txt");
assertNull(service.validateSession("d2"));
assertFalse(Files.exists(tempDir.resolve("d2")));
}
@Test
@DisplayName("does not throw for an unknown session")
void unknownSessionNoThrow() {
assertDoesNotThrow(() -> service.deleteFileAfterDownload("ghost", "a.txt"));
}
@Test
@DisplayName("swallows invalid filename input without throwing")
void invalidFilenameNoThrow() throws IOException {
service.createSession("d3");
service.uploadFiles("d3", List.of(file("a.txt", "x")));
assertDoesNotThrow(() -> service.deleteFileAfterDownload("d3", "../escape.txt"));
// Original file untouched.
assertTrue(Files.exists(tempDir.resolve("d3").resolve("a.txt")));
}
}
@Nested
@DisplayName("deleteSession")
class DeleteSession {
@Test
@DisplayName("removes the session and all its files")
void removesSessionAndFiles() throws IOException {
service.createSession("x1");
service.uploadFiles("x1", List.of(file("a.txt", "x"), file("b.txt", "y")));
assertTrue(Files.exists(tempDir.resolve("x1")));
service.deleteSession("x1");
assertNull(service.validateSession("x1"));
assertFalse(Files.exists(tempDir.resolve("x1")));
}
@Test
@DisplayName("is a no-op for an unknown session")
void unknownSessionNoOp() {
assertDoesNotThrow(() -> service.deleteSession("never-existed"));
}
}
@Nested
@DisplayName("cleanupExpiredSessions")
class CleanupExpiredSessions {
@Test
@DisplayName("removes sessions past the timeout")
void removesExpired() throws IOException {
service.createSession("old");
service.uploadFiles("old", List.of(file("a.txt", "x")));
forceLastAccess("old", System.currentTimeMillis() - (20 * 60 * 1000L));
service.cleanupExpiredSessions();
assertNull(service.validateSession("old"));
assertFalse(Files.exists(tempDir.resolve("old")));
}
@Test
@DisplayName("keeps sessions that are still fresh")
void keepsFresh() {
service.createSession("fresh");
service.cleanupExpiredSessions();
assertNotNull(service.validateSession("fresh"));
}
@Test
@DisplayName("does not throw when there are no sessions")
void noSessionsNoThrow() {
assertDoesNotThrow(() -> service.cleanupExpiredSessions());
}
}
@Nested
@DisplayName("SessionInfo accessors")
class SessionInfoAccessors {
@Test
@DisplayName("exposes all constructor values")
void exposesValues() {
SessionInfo info = new SessionInfo("id", 100L, 200L, 50L);
assertEquals("id", info.getSessionId());
assertEquals(100L, info.getCreatedAt());
assertEquals(200L, info.getExpiresAt());
assertEquals(50L, info.getTimeoutMs());
}
}
@Nested
@DisplayName("FileMetadata accessors")
class FileMetadataAccessors {
@Test
@DisplayName("exposes all constructor values")
void exposesValues() {
FileMetadata meta = new FileMetadata("name.pdf", 1234L, "application/pdf");
assertEquals("name.pdf", meta.getFilename());
assertEquals(1234L, meta.getSize());
assertEquals("application/pdf", meta.getContentType());
}
}
/**
* Reaches into the internal SessionData for a given session and forces its lastAccessTime, used
* to deterministically simulate expiry without sleeping.
*/
@SuppressWarnings("unchecked")
private void forceLastAccess(String sessionId, long lastAccessTime) {
java.util.Map<String, Object> sessions =
(java.util.Map<String, Object>)
ReflectionTestUtils.getField(service, "activeSessions");
assertNotNull(sessions);
Object sessionData = sessions.get(sessionId);
assertNotNull(sessionData, "session not found: " + sessionId);
ReflectionTestUtils.setField(sessionData, "lastAccessTime", lastAccessTime);
}
}
@@ -0,0 +1,416 @@
package stirling.software.common.service;
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.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Calendar;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
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 stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Premium;
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures;
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.CustomMetadata;
import stirling.software.common.model.PdfMetadata;
class PdfMetadataServiceTest {
private static final String LABEL = "Stirling-PDF v1.0.0";
/**
* Builds a service whose pro-features are disabled (real ApplicationProperties, all defaults).
*/
private PdfMetadataService nonProService(UserServiceInterface userService) {
return new PdfMetadataService(new ApplicationProperties(), LABEL, false, userService);
}
@Nested
@DisplayName("toCalendar(ZonedDateTime)")
class ToCalendarTests {
@Test
@DisplayName("returns null for null input")
void nullReturnsNull() {
assertNull(PdfMetadataService.toCalendar(null));
}
@Test
@DisplayName("converts ZonedDateTime preserving the instant")
void convertsInstant() {
ZonedDateTime zdt = ZonedDateTime.of(2021, 6, 15, 10, 30, 45, 0, ZoneId.of("UTC"));
Calendar cal = PdfMetadataService.toCalendar(zdt);
assertNotNull(cal);
assertEquals(zdt.toInstant().toEpochMilli(), cal.getTimeInMillis());
}
}
@Nested
@DisplayName("parseToCalendar(String)")
class ParseToCalendarTests {
@Test
@DisplayName("returns null for null input")
void nullReturnsNull() {
assertNull(PdfMetadataService.parseToCalendar(null));
}
@Test
@DisplayName("returns null for empty / blank input")
void blankReturnsNull() {
assertNull(PdfMetadataService.parseToCalendar(""));
assertNull(PdfMetadataService.parseToCalendar(" "));
}
@Test
@DisplayName("returns null for unparsable input")
void invalidReturnsNull() {
assertNull(PdfMetadataService.parseToCalendar("not a date"));
assertNull(PdfMetadataService.parseToCalendar("2021-06-15"));
assertNull(PdfMetadataService.parseToCalendar("2021/13/40 99:99:99"));
}
@Test
@DisplayName("parses a valid 'yyyy/MM/dd HH:mm:ss' string")
void parsesValidDate() {
Calendar cal = PdfMetadataService.parseToCalendar("2021/06/15 10:30:45");
assertNotNull(cal);
// Build the expected instant the same way the implementation does so the
// assertion is independent of the JVM's default time zone.
long expectedMillis =
LocalDateTime.of(2021, 6, 15, 10, 30, 45)
.atZone(ZoneId.systemDefault())
.toInstant()
.toEpochMilli();
assertEquals(expectedMillis, cal.getTimeInMillis());
}
}
@Nested
@DisplayName("extractMetadataFromPdf(PDDocument)")
class ExtractMetadataTests {
@Test
@DisplayName("returns all-null fields for a fresh empty document")
void emptyDocumentYieldsNulls() throws Exception {
PdfMetadataService service = nonProService(null);
try (PDDocument doc = new PDDocument()) {
PdfMetadata md = service.extractMetadataFromPdf(doc);
assertNotNull(md);
assertNull(md.getAuthor());
assertNull(md.getProducer());
assertNull(md.getTitle());
assertNull(md.getCreator());
assertNull(md.getSubject());
assertNull(md.getKeywords());
assertNull(md.getCreationDate());
assertNull(md.getModificationDate());
}
}
@Test
@DisplayName("reads back string and date fields set on the document")
void readsBackPopulatedFields() throws Exception {
PdfMetadataService service = nonProService(null);
try (PDDocument doc = new PDDocument()) {
PDDocumentInformation info = doc.getDocumentInformation();
info.setAuthor("Alice");
info.setProducer("ProducerX");
info.setTitle("My Title");
info.setCreator("CreatorY");
info.setSubject("Subject Z");
info.setKeywords("k1, k2");
Calendar creation = Calendar.getInstance();
creation.setTimeInMillis(1_600_000_000_000L);
Calendar modification = Calendar.getInstance();
modification.setTimeInMillis(1_700_000_000_000L);
info.setCreationDate(creation);
info.setModificationDate(modification);
PdfMetadata md = service.extractMetadataFromPdf(doc);
assertEquals("Alice", md.getAuthor());
assertEquals("ProducerX", md.getProducer());
assertEquals("My Title", md.getTitle());
assertEquals("CreatorY", md.getCreator());
assertEquals("Subject Z", md.getSubject());
assertEquals("k1, k2", md.getKeywords());
assertNotNull(md.getCreationDate());
assertNotNull(md.getModificationDate());
assertEquals(1_600_000_000_000L, md.getCreationDate().toInstant().toEpochMilli());
assertEquals(
1_700_000_000_000L, md.getModificationDate().toInstant().toEpochMilli());
}
}
}
@Nested
@DisplayName("setMetadataToPdf / setDefaultMetadata (non-pro path)")
class SetMetadataNonProTests {
@Test
@DisplayName("writes producer label, title, subject, keywords and author from metadata")
void writesCommonMetadata() throws Exception {
PdfMetadataService service = nonProService(null);
PdfMetadata md =
PdfMetadata.builder()
.author("Bob")
.title("Doc Title")
.subject("Doc Subject")
.keywords("a, b, c")
.creationDate(
ZonedDateTime.of(2020, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")))
.modificationDate(
ZonedDateTime.of(2021, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")))
.build();
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
service.setMetadataToPdf(doc, md);
PDDocumentInformation info = doc.getDocumentInformation();
assertEquals(LABEL, info.getProducer());
assertEquals("Doc Title", info.getTitle());
assertEquals("Doc Subject", info.getSubject());
assertEquals("a, b, c", info.getKeywords());
// Non-pro: author is taken verbatim from the metadata.
assertEquals("Bob", info.getAuthor());
assertNotNull(info.getModificationDate());
}
}
@Test
@DisplayName("existing creation date is left untouched when not newly created")
void keepsExistingCreationDate() throws Exception {
PdfMetadataService service = nonProService(null);
ZonedDateTime creation = ZonedDateTime.of(2019, 5, 20, 8, 15, 0, 0, ZoneId.of("UTC"));
PdfMetadata md = PdfMetadata.builder().title("T").creationDate(creation).build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md);
Calendar creationCal = doc.getDocumentInformation().getCreationDate();
// creationDate is non-null and newlyCreated=false, so setNewDocumentMetadata
// is skipped and no creation date is written.
assertNull(creationCal);
}
}
@Test
@DisplayName("sets a fresh creation date when metadata has none")
void setsCreationDateWhenMissing() throws Exception {
PdfMetadataService service = nonProService(null);
PdfMetadata md = PdfMetadata.builder().title("T").build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md);
Calendar creationCal = doc.getDocumentInformation().getCreationDate();
assertNotNull(creationCal);
// Non-pro path writes the Stirling label as the creator.
assertEquals(LABEL, doc.getDocumentInformation().getCreator());
}
}
@Test
@DisplayName("newlyCreated=true forces a fresh creation date even if metadata has one")
void newlyCreatedForcesCreationDate() throws Exception {
PdfMetadataService service = nonProService(null);
ZonedDateTime creation = ZonedDateTime.of(2018, 3, 3, 3, 3, 3, 0, ZoneId.of("UTC"));
PdfMetadata md = PdfMetadata.builder().title("T").creationDate(creation).build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md, true);
Calendar creationCal = doc.getDocumentInformation().getCreationDate();
assertNotNull(creationCal);
// The supplied creation date must have been honoured (not "now").
assertEquals(creation.toInstant().toEpochMilli(), creationCal.getTimeInMillis());
assertEquals(LABEL, doc.getDocumentInformation().getCreator());
}
}
@Test
@DisplayName(
"setDefaultMetadata round-trips existing document info through the producer label")
void setDefaultMetadataRewritesProducer() throws Exception {
PdfMetadataService service = nonProService(null);
try (PDDocument doc = new PDDocument()) {
PDDocumentInformation info = doc.getDocumentInformation();
info.setTitle("Original Title");
info.setAuthor("Original Author");
info.setProducer("Some Other Producer");
service.setDefaultMetadata(doc);
// extract + re-apply keeps title/author but rewrites producer to the label.
assertEquals("Original Title", info.getTitle());
assertEquals("Original Author", info.getAuthor());
assertEquals(LABEL, info.getProducer());
}
}
@Test
@DisplayName("null string fields in metadata are written through without error")
void handlesNullStringFields() throws Exception {
PdfMetadataService service = nonProService(null);
PdfMetadata md = PdfMetadata.builder().build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md, true);
PDDocumentInformation info = doc.getDocumentInformation();
assertEquals(LABEL, info.getProducer());
assertNull(info.getTitle());
assertNull(info.getSubject());
assertNull(info.getKeywords());
assertNull(info.getAuthor());
// newlyCreated=true always stamps a creation date.
assertNotNull(info.getCreationDate());
assertNotNull(info.getModificationDate());
}
}
}
@Nested
@DisplayName("setMetadataToPdf (pro path with custom metadata)")
class SetMetadataProTests {
private ApplicationProperties propsWithCustomMetadata(
boolean autoUpdate, String author, String creator) {
ApplicationProperties props = mock(ApplicationProperties.class);
Premium premium = mock(Premium.class);
ProFeatures proFeatures = mock(ProFeatures.class);
CustomMetadata customMetadata = mock(CustomMetadata.class);
lenient().when(props.getPremium()).thenReturn(premium);
lenient().when(premium.getProFeatures()).thenReturn(proFeatures);
lenient().when(proFeatures.getCustomMetadata()).thenReturn(customMetadata);
lenient().when(customMetadata.isAutoUpdateMetadata()).thenReturn(autoUpdate);
lenient().when(customMetadata.getAuthor()).thenReturn(author);
lenient().when(customMetadata.getCreator()).thenReturn(creator);
return props;
}
@Test
@DisplayName("uses custom author and creator when pro and auto-update enabled")
void appliesCustomAuthorAndCreator() throws Exception {
ApplicationProperties props =
propsWithCustomMetadata(true, "Custom Author", "Custom Creator");
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, null);
PdfMetadata md = PdfMetadata.builder().author("Ignored").title("T").build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md, true);
PDDocumentInformation info = doc.getDocumentInformation();
assertEquals("Custom Author", info.getAuthor());
assertEquals("Custom Creator", info.getCreator());
// Producer is set to the label by both setNewDocumentMetadata and
// setCommonMetadata.
assertEquals(LABEL, info.getProducer());
}
}
@Test
@DisplayName("replaces 'username' token with the current user when userService present")
void replacesUsernameToken() throws Exception {
ApplicationProperties props =
propsWithCustomMetadata(true, "Report by username", "Creator");
UserServiceInterface userService = mock(UserServiceInterface.class);
when(userService.getCurrentUsername()).thenReturn("alice");
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, userService);
PdfMetadata md = PdfMetadata.builder().title("T").build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md, true);
assertEquals("Report by alice", doc.getDocumentInformation().getAuthor());
}
}
@Test
@DisplayName("leaves 'username' token intact when current user is null")
void keepsTokenWhenUsernameNull() throws Exception {
ApplicationProperties props =
propsWithCustomMetadata(true, "Report by username", "Creator");
UserServiceInterface userService = mock(UserServiceInterface.class);
when(userService.getCurrentUsername()).thenReturn(null);
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, userService);
PdfMetadata md = PdfMetadata.builder().title("T").build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md, true);
assertEquals("Report by username", doc.getDocumentInformation().getAuthor());
}
}
@Test
@DisplayName("custom author applied even without a userService")
void appliesCustomAuthorWithoutUserService() throws Exception {
ApplicationProperties props = propsWithCustomMetadata(true, "Static Author", "Creator");
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, null);
PdfMetadata md = PdfMetadata.builder().title("T").build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md, true);
assertEquals("Static Author", doc.getDocumentInformation().getAuthor());
}
}
@Test
@DisplayName("pro flag without auto-update keeps metadata author and label creator")
void proButAutoUpdateDisabledUsesMetadata() throws Exception {
ApplicationProperties props =
propsWithCustomMetadata(false, "Custom Author", "Custom Creator");
PdfMetadataService service = new PdfMetadataService(props, LABEL, true, null);
PdfMetadata md = PdfMetadata.builder().author("Metadata Author").title("T").build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md, true);
PDDocumentInformation info = doc.getDocumentInformation();
assertEquals("Metadata Author", info.getAuthor());
assertEquals(LABEL, info.getCreator());
}
}
@Test
@DisplayName("auto-update enabled but not pro keeps metadata author and label creator")
void autoUpdateButNotProUsesMetadata() throws Exception {
ApplicationProperties props =
propsWithCustomMetadata(true, "Custom Author", "Custom Creator");
PdfMetadataService service = new PdfMetadataService(props, LABEL, false, null);
PdfMetadata md = PdfMetadata.builder().author("Metadata Author").title("T").build();
try (PDDocument doc = new PDDocument()) {
service.setMetadataToPdf(doc, md, true);
PDDocumentInformation info = doc.getDocumentInformation();
assertEquals("Metadata Author", info.getAuthor());
assertEquals(LABEL, info.getCreator());
}
}
}
}
@@ -0,0 +1,441 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.HashMap;
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.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.mock.env.MockEnvironment;
import com.posthog.java.PostHog;
import stirling.software.common.model.ApplicationProperties;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class PostHogServiceTest {
private static final String UUID = "test-uuid-1234";
private static final String APP_VERSION = "9.9.9";
@Mock PostHog postHog;
@Mock UserServiceInterface userService;
/** Build an ApplicationProperties with analytics/posthog toggled. */
private ApplicationProperties props(boolean analyticsEnabled) {
ApplicationProperties appProps = new ApplicationProperties();
appProps.getSystem().setEnableAnalytics(analyticsEnabled);
return appProps;
}
/** Construct the service under test. */
private PostHogService newService(
ApplicationProperties appProps,
UserServiceInterface user,
boolean configDirMounted,
MockEnvironment env) {
return new PostHogService(
postHog, UUID, configDirMounted, APP_VERSION, appProps, user, env);
}
private MockEnvironment env() {
return new MockEnvironment();
}
@Nested
@DisplayName("Constructor / captureSystemInfo")
class ConstructorBehavior {
@Test
@DisplayName("constructor captures system_info when posthog is enabled")
void constructorCapturesWhenEnabled() {
ApplicationProperties appProps = props(true);
newService(appProps, userService, false, env());
verify(postHog).capture(eq(UUID), eq("system_info_captured"), anyMap());
}
@Test
@DisplayName("constructor does not capture when analytics disabled")
void constructorNoCaptureWhenDisabled() {
ApplicationProperties appProps = props(false);
newService(appProps, userService, false, env());
verify(postHog, never()).capture(anyString(), anyString(), anyMap());
}
@Test
@DisplayName("constructor does not capture when posthog explicitly disabled")
void constructorNoCaptureWhenPosthogOff() {
ApplicationProperties appProps = props(true);
appProps.getSystem().setEnablePosthog(false);
newService(appProps, userService, false, env());
verify(postHog, never()).capture(anyString(), anyString(), anyMap());
}
@Test
@DisplayName("constructor swallows exceptions thrown by postHog.capture")
void constructorSwallowsCaptureException() {
ApplicationProperties appProps = props(true);
doThrow(new RuntimeException("boom"))
.when(postHog)
.capture(anyString(), anyString(), anyMap());
// Must not propagate; constructor wraps capture in try/catch.
assertDoesNotThrow(() -> newService(appProps, userService, false, env()));
}
@Test
@DisplayName("constructor works with null userService (optional dependency)")
void constructorWithNullUserService() {
ApplicationProperties appProps = props(true);
assertDoesNotThrow(() -> newService(appProps, null, false, env()));
verify(postHog).capture(eq(UUID), eq("system_info_captured"), anyMap());
}
}
@Nested
@DisplayName("captureEvent")
class CaptureEvent {
@Test
@DisplayName("captureEvent forwards to postHog when enabled and injects app_version")
void captureEventWhenEnabled() {
ApplicationProperties appProps = props(true);
PostHogService service = newService(appProps, userService, false, env());
// Reset the constructor's capture so we only assert on captureEvent.
clearInvocations(postHog);
Map<String, Object> properties = new HashMap<>();
properties.put("foo", "bar");
service.captureEvent("my_event", properties);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> captor = ArgumentCaptor.forClass(Map.class);
verify(postHog).capture(eq(UUID), eq("my_event"), captor.capture());
Map<String, Object> sent = captor.getValue();
assertEquals("bar", sent.get("foo"));
assertEquals(APP_VERSION, sent.get("app_version"));
}
@Test
@DisplayName("captureEvent is a no-op when analytics disabled")
void captureEventWhenDisabled() {
ApplicationProperties appProps = props(false);
PostHogService service = newService(appProps, userService, false, env());
clearInvocations(postHog);
Map<String, Object> properties = new HashMap<>();
service.captureEvent("my_event", properties);
verify(postHog, never()).capture(anyString(), anyString(), anyMap());
// app_version must not be added when disabled (early return).
assertFalse(properties.containsKey("app_version"));
}
@Test
@DisplayName("captureEvent adds app_version key to the provided map")
void captureEventMutatesMap() {
ApplicationProperties appProps = props(true);
PostHogService service = newService(appProps, userService, false, env());
clearInvocations(postHog);
Map<String, Object> properties = new HashMap<>();
service.captureEvent("evt", properties);
assertTrue(properties.containsKey("app_version"));
assertEquals(APP_VERSION, properties.get("app_version"));
}
}
@Nested
@DisplayName("captureServerMetrics")
class CaptureServerMetrics {
private PostHogService disabledService() {
// Keep posthog disabled so the constructor performs no capture; metrics
// methods are independent of the enabled flag.
return newService(props(false), userService, true, env());
}
@Test
@DisplayName("includes core application and system metrics")
void includesCoreMetrics() {
PostHogService service = disabledService();
Map<String, Object> metrics = service.captureServerMetrics();
assertEquals(APP_VERSION, metrics.get("app_version"));
assertEquals(true, metrics.get("mounted_config_dir"));
assertNotNull(metrics.get("os_name"));
assertNotNull(metrics.get("java_version"));
assertTrue(metrics.containsKey("cpu_cores"));
assertTrue(metrics.containsKey("total_memory"));
assertTrue(metrics.containsKey("free_memory"));
assertTrue(metrics.containsKey("process_id"));
assertTrue(metrics.containsKey("jvm_uptime_ms"));
assertTrue(metrics.containsKey("thread_count"));
}
@Test
@DisplayName("deployment_type defaults to JAR when not docker/exe")
void deploymentTypeJar() {
PostHogService service = disabledService();
Map<String, Object> metrics = service.captureServerMetrics();
// In the unit-test environment there is no /.dockerenv and no BROWSER_OPEN.
assertEquals("JAR", metrics.get("deployment_type"));
}
@Test
@DisplayName("deployment_type becomes EXE when BROWSER_OPEN=true")
void deploymentTypeExe() {
MockEnvironment environment = env();
environment.setProperty("BROWSER_OPEN", "true");
PostHogService service = newService(props(false), userService, false, environment);
Map<String, Object> metrics = service.captureServerMetrics();
assertEquals("EXE", metrics.get("deployment_type"));
}
@Test
@DisplayName("BROWSER_OPEN matching is case-insensitive")
void deploymentTypeExeCaseInsensitive() {
MockEnvironment environment = env();
environment.setProperty("BROWSER_OPEN", "TRUE");
PostHogService service = newService(props(false), userService, false, environment);
Map<String, Object> metrics = service.captureServerMetrics();
assertEquals("EXE", metrics.get("deployment_type"));
}
@Test
@DisplayName("mounted_config_dir reflects the configDirMounted flag")
void mountedConfigDirFalse() {
PostHogService service = newService(props(false), userService, false, env());
Map<String, Object> metrics = service.captureServerMetrics();
assertEquals(false, metrics.get("mounted_config_dir"));
}
@Test
@DisplayName("includes total_users_created when userService present")
void includesUserCountWhenUserServicePresent() {
when(userService.getTotalUsersCount()).thenReturn(42L);
PostHogService service = newService(props(false), userService, false, env());
Map<String, Object> metrics = service.captureServerMetrics();
assertEquals(42L, metrics.get("total_users_created"));
}
@Test
@DisplayName("omits total_users_created when userService is null")
void omitsUserCountWhenUserServiceNull() {
PostHogService service = newService(props(false), null, false, env());
Map<String, Object> metrics = service.captureServerMetrics();
assertFalse(metrics.containsKey("total_users_created"));
}
@Test
@DisplayName("always embeds nested application_properties map")
void embedsApplicationProperties() {
PostHogService service = disabledService();
Map<String, Object> metrics = service.captureServerMetrics();
assertTrue(metrics.get("application_properties") instanceof Map);
}
}
@Nested
@DisplayName("captureApplicationProperties")
class CaptureApplicationProperties {
private PostHogService serviceWith(ApplicationProperties appProps) {
// Disable analytics to keep the constructor from capturing.
appProps.getSystem().setEnableAnalytics(false);
return newService(appProps, userService, false, env());
}
@Test
@DisplayName("includes blank-trimmed legal strings only when non-empty")
void legalPropertiesFiltered() {
ApplicationProperties appProps = new ApplicationProperties();
appProps.getLegal().setTermsAndConditions(" https://terms ");
appProps.getLegal().setPrivacyPolicy(""); // blank -> skipped
PostHogService service = serviceWith(appProps);
Map<String, Object> p = service.captureApplicationProperties();
// String values are trimmed by addIfNotEmpty.
assertEquals("https://terms", p.get("legal_termsAndConditions"));
assertFalse(p.containsKey("legal_privacyPolicy"));
assertFalse(p.containsKey("legal_accessibilityStatement"));
}
@Test
@DisplayName("always reports csrfDisabled true and login booleans")
void securityProperties() {
ApplicationProperties appProps = new ApplicationProperties();
appProps.getSecurity().setEnableLogin(true);
appProps.getSecurity().setLoginAttemptCount(5);
appProps.getSecurity().setLoginResetTimeMinutes(10);
PostHogService service = serviceWith(appProps);
Map<String, Object> p = service.captureApplicationProperties();
assertEquals(true, p.get("security_csrfDisabled"));
assertEquals(true, p.get("security_enableLogin"));
assertEquals(5, p.get("security_loginAttemptCount"));
assertEquals(10L, p.get("security_loginResetTimeMinutes"));
assertEquals("all", p.get("security_loginMethod"));
}
@Test
@DisplayName("oauth2 nested fields are omitted when oauth2 disabled")
void oauth2DisabledOmitsNested() {
ApplicationProperties appProps = new ApplicationProperties();
// oauth2.enabled defaults to false.
PostHogService service = serviceWith(appProps);
Map<String, Object> p = service.captureApplicationProperties();
assertEquals(false, p.get("security_oauth2_enabled"));
assertFalse(p.containsKey("security_oauth2_autoCreateUser"));
assertFalse(p.containsKey("security_oauth2_provider"));
}
@Test
@DisplayName("oauth2 nested fields are included when oauth2 enabled")
void oauth2EnabledIncludesNested() {
ApplicationProperties appProps = new ApplicationProperties();
appProps.getSecurity().getOauth2().setEnabled(true);
appProps.getSecurity().getOauth2().setAutoCreateUser(true);
appProps.getSecurity().getOauth2().setBlockRegistration(false);
appProps.getSecurity().getOauth2().setUseAsUsername("email");
appProps.getSecurity().getOauth2().setProvider("google");
PostHogService service = serviceWith(appProps);
Map<String, Object> p = service.captureApplicationProperties();
assertEquals(true, p.get("security_oauth2_enabled"));
assertEquals(true, p.get("security_oauth2_autoCreateUser"));
assertEquals(false, p.get("security_oauth2_blockRegistration"));
assertEquals("email", p.get("security_oauth2_useAsUsername"));
assertEquals("google", p.get("security_oauth2_provider"));
}
@Test
@DisplayName("system analytics/posthog/scarf booleans are reported")
void systemAnalyticsBooleans() {
ApplicationProperties appProps = new ApplicationProperties();
appProps.getSystem().setEnableAnalytics(true);
appProps.getSystem().setEnablePosthog(true);
appProps.getSystem().setEnableScarf(false);
appProps.getSystem().setDefaultLocale("en-US");
PostHogService service = newService(appProps, userService, false, env());
// Constructor will capture once because analytics is enabled; that's fine.
clearInvocations(postHog);
Map<String, Object> p = service.captureApplicationProperties();
assertEquals("en-US", p.get("system_defaultLocale"));
assertEquals(true, p.get("system_enableAnalytics"));
assertEquals(true, p.get("system_enablePosthog"));
// isScarfEnabled() is false because enableScarf is false.
assertEquals(false, p.get("system_enableScarf"));
}
@Test
@DisplayName("metrics_enabled and autoPipeline output folder included appropriately")
void metricsAndAutoPipeline() {
ApplicationProperties appProps = new ApplicationProperties();
appProps.getMetrics().setEnabled(true);
appProps.getAutoPipeline().setOutputFolder("/tmp/out");
PostHogService service = serviceWith(appProps);
Map<String, Object> p = service.captureApplicationProperties();
assertEquals(true, p.get("metrics_enabled"));
assertEquals("/tmp/out", p.get("autoPipeline_outputFolder"));
}
@Test
@DisplayName("enterprise metadata flag omitted when premium disabled")
void premiumDisabledOmitsMetadata() {
ApplicationProperties appProps = new ApplicationProperties();
// premium.enabled defaults to false.
PostHogService service = serviceWith(appProps);
Map<String, Object> p = service.captureApplicationProperties();
assertEquals(false, p.get("enterpriseEdition_enabled"));
assertFalse(p.containsKey("enterpriseEdition_customMetadata_autoUpdateMetadata"));
}
@Test
@DisplayName("enterprise metadata flag included when premium enabled")
void premiumEnabledIncludesMetadata() {
ApplicationProperties appProps = new ApplicationProperties();
appProps.getPremium().setEnabled(true);
appProps.getPremium().getProFeatures().getCustomMetadata().setAutoUpdateMetadata(true);
PostHogService service = serviceWith(appProps);
Map<String, Object> p = service.captureApplicationProperties();
assertEquals(true, p.get("enterpriseEdition_enabled"));
assertEquals(true, p.get("enterpriseEdition_customMetadata_autoUpdateMetadata"));
}
@Test
@DisplayName("ui appNameNavbar omitted when blank, included when set")
void uiAppNameNavbar() {
ApplicationProperties blankProps = new ApplicationProperties();
// appNameNavbar getter returns null for blank/empty values.
PostHogService blankService = serviceWith(blankProps);
Map<String, Object> blank = blankService.captureApplicationProperties();
assertFalse(blank.containsKey("ui_appNameNavbar"));
ApplicationProperties namedProps = new ApplicationProperties();
namedProps.getUi().setAppNameNavbar("My App");
PostHogService namedService = serviceWith(namedProps);
Map<String, Object> named = namedService.captureApplicationProperties();
assertEquals("My App", named.get("ui_appNameNavbar"));
}
@Test
@DisplayName("returns a non-null map for a fresh ApplicationProperties")
void defaultsProduceNonNullMap() {
PostHogService service = serviceWith(new ApplicationProperties());
Map<String, Object> p = service.captureApplicationProperties();
assertNotNull(p);
// csrfDisabled is always added regardless of config, so map is never empty.
assertTrue(p.containsKey("security_csrfDisabled"));
}
}
}
@@ -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);
}
}
}

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