Compare commits

...
Author SHA1 Message Date
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
443 changed files with 20464 additions and 13667 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.0
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.0
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
@@ -1 +0,0 @@
pre-commit
-121
View File
@@ -1,121 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in'
#
cfgv==3.5.0 \
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
# via pre-commit
distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
# via virtualenv
filelock==3.29.0 \
--hash=sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90 \
--hash=sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258
# via
# python-discovery
# virtualenv
identify==2.6.19 \
--hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \
--hash=sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842
# via pre-commit
nodeenv==1.10.0 \
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
# via pre-commit
platformdirs==4.9.6 \
--hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \
--hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917
# via
# python-discovery
# virtualenv
pre-commit==4.6.0 \
--hash=sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9 \
--hash=sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b
# via -r .github/scripts/requirements_pre_commit.in
python-discovery==1.2.2 \
--hash=sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb \
--hash=sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a
# via virtualenv
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via pre-commit
virtualenv==21.2.4 \
--hash=sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac \
--hash=sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada
# via pre-commit
+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
+11 -3
View File
@@ -58,15 +58,23 @@ jobs:
- name: Install Python dependencies
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt -r ./.github/scripts/requirements_pre_commit.txt
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
- name: Sync translation TOML files
run: |
python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
- name: pre-commit run
- name: Sort translation TOML files
run: |
pre-commit run toml-sort-fix --all-files
task pre-commit:toml-sort FIX=1
- name: Commit translation files
run: |
+7
View File
@@ -46,6 +46,11 @@ 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
# 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 +58,8 @@ app/core/src/main/resources/static/modern-logo/
app/core/src/main/resources/static/og_images/
app/core/src/main/resources/static/samples/
app/core/src/main/resources/static/manifest-classic.json
app/core/src/main/resources/static/og-metadata.json
app/core/src/main/resources/static/sw-folder-retry.js
app/core/src/main/resources/static/robots.txt
app/core/src/main/resources/static/pdfium/
app/core/src/main/resources/static/pdfjs/
+8 -1
View File
@@ -1,4 +1,4 @@
# PostHog project-level key phc_ prefix keys are public/client-side by design
# PostHog project-level key - phc_ prefix keys are public/client-side by design
# (PostHog client-side tracking embeds them in the browser bundle). Committed
# intentionally in #6150 so engine/.env has a working default, with real
# credentials overridden via engine/.env.local.
@@ -12,3 +12,10 @@ app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiK
testing/compose/docker-compose-keycloak-mcp.yml:generic-api-key:25
testing/compose/validate-mcp-apikey.sh:curl-auth-header:73
testing/compose/validate-mcp-test.sh:curl-auth-header:92
testing/compose/validate-mcp-test.sh:curl-auth-header:116
# Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...).
frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
+12 -50
View File
@@ -1,52 +1,14 @@
# The actual checks live in .taskfiles/pre-commit.yml (with helper scripts under
# scripts/pre-commit/) and are driven by Task. This hook just delegates to `task
# pre-commit` so the git pre-commit hook, CI and a manual `task pre-commit` all
# run the exact same thing. Requires `task` and `uv` on PATH. To auto-fix instead
# of only checking, run `task pre-commit:fix`.
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.14
- repo: local
hooks:
- id: ruff
args:
- --fix
- --line-length=127
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
exclude: (split_photos.py)
- id: ruff-format
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
exclude: (split_photos.py)
- repo: https://github.com/codespell-project/codespell
rev: v2.4.2
hooks:
- id: codespell
args:
- --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment
- --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2
files: \.(html|css|js|py|md)$
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|frontend/editor/public/vendor|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.0
hooks:
- id: gitleaks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: end-of-file-fixer
files: ^.*(\.js|\.java|\.py|\.yml)$
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
- id: trailing-whitespace
files: ^.*(\.js|\.java|\.py|\.yml)$
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
- repo: https://github.com/pappasam/toml-sort
rev: v0.24.4
hooks:
- id: toml-sort-fix
files: frontend/editor/public/locales/.*\.toml$
args: ['--in-place', '--all', '--ignore-case']
# - repo: https://github.com/thibaudcolas/pre-commit-stylelint
# rev: v16.21.1
# hooks:
# - id: stylelint
# additional_dependencies:
# - stylelint@16.21.1
# - stylelint-config-standard@38.0.0
# - "@stylistic/stylelint-plugin@3.1.3"
# files: \.(css)$
# args: [--fix]
- id: task-pre-commit
name: task pre-commit
entry: task pre-commit
language: system
pass_filenames: false
always_run: true
+6 -3
View File
@@ -23,6 +23,7 @@ tasks:
vars:
PORT: '{{.PORT}}'
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
dev:proprietary:
@@ -31,13 +32,14 @@ tasks:
vars:
PORT: '{{.PORT | default "8080"}}'
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
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}}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}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
@@ -60,12 +62,13 @@ tasks:
# Override to "" to run the pure `saas` profile against your own SAAS_DB_*.
PROFILES: '{{.PROFILES | default "dev"}}'
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
env:
SERVER_PORT: '{{.PORT}}'
STIRLING_FLAVOR: saas
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{if .AIENGINE_URL}}true{{else}}false{{end}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
cmds:
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}"
+36
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
@@ -317,9 +333,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
@@ -374,3 +398,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]
+159
View File
@@ -0,0 +1,159 @@
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:
GITLEAKS: '8.30.0'
# File selections as git pathspecs: git does the include/exclude matching, so
# there is no grep/xargs and it behaves identically on every platform.
PY_FILES: >-
'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_BIN: '.task/bin/gitleaks-{{.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 cache/build artifacts"
cmds:
- task: '{{if eq OS "windows"}}clean-windows{{else}}clean-unix{{end}}'
clean-unix:
internal: true
cmds:
- rm -rf scripts/pre-commit/.venv .task/bin/gitleaks-*
# On Windows, use PowerShell so it matches the same paths and tolerates absent
# files without erroring.
clean-windows:
internal: true
ignore_error: true
cmds:
- powershell -NoProfile -Command "Remove-Item -Recurse -Force -ErrorAction SilentlyContinue scripts/pre-commit/.venv, .task/bin/gitleaks-*"
# 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}}$(git ls-files {{.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 gitleaks binary is cached in .task/bin"
status:
- test -f {{.GITLEAKS_BIN}}
vars:
GL_ARCH: '{{if eq ARCH "amd64"}}x64{{else if eq ARCH "arm64"}}arm64{{else if eq ARCH "386"}}x32{{else}}{{ARCH}}{{end}}'
GL_PLATFORM: '{{OS}}_{{.GL_ARCH}}'
GL_URL: 'https://github.com/gitleaks/gitleaks/releases/download/v{{.GITLEAKS}}/gitleaks_{{.GITLEAKS}}_{{.GL_PLATFORM}}'
# SHA-256 of each release asset, from gitleaks_{{.GITLEAKS}}_checksums.txt.
GL_SHA: >-
{{if eq .GL_PLATFORM "linux_x64"}}79a3ab579b53f71efd634f3aaf7e04a0fa0cf206b7ed434638d1547a2470a66e
{{- else if eq .GL_PLATFORM "linux_arm64"}}b4cbbb6ddf7d1b2a603088cd03a4e3f7ce48ee7fd449b51f7de6ee2906f5fa2f
{{- else if eq .GL_PLATFORM "darwin_x64"}}ca221d012d247080c2f6f61f4b7a83bffa2453806b0c195c795bbe9a8c775ed5
{{- else if eq .GL_PLATFORM "darwin_arm64"}}b251ab2bcd4cd8ba9e56ff37698c033ebf38582b477d21ebd86586d927cf87e7
{{- else if eq .GL_PLATFORM "windows_x64"}}54fe94f644b832dd08e8c3a5915efb3bfa862386d59fb27ca0792cb687a83573
{{- end}}
cmds:
- cmd: bash scripts/pre-commit/install-gitleaks.sh "{{.GL_URL}}.tar.gz" "{{.GL_SHA}}" "{{.GITLEAKS_BIN}}"
platforms: [linux, darwin]
- cmd: powershell -NoProfile -File scripts/pre-commit/install-gitleaks.ps1 -Url "{{.GL_URL}}.zip" -Sha "{{.GL_SHA}}" -Dest "{{.GITLEAKS_BIN}}"
platforms: [windows]
+2
View File
@@ -193,6 +193,8 @@ What goes where:
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.
+6
View File
@@ -25,6 +25,9 @@ includes:
e2e:
taskfile: .taskfiles/e2e.yml
dir: .
pre-commit:
taskfile: .taskfiles/pre-commit.yml
dir: .
tasks:
# ============================================================
@@ -87,6 +90,7 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
AIENGINE_ENABLED: "true"
- task: 'frontend:dev:{{.FRONTEND}}'
vars:
PORT: '{{.FRONTEND_PORT}}'
@@ -171,4 +175,6 @@ tasks:
desc: "Clean all build artifacts"
cmds:
- task: backend:clean
- task: frontend:clean
- task: engine:clean
- task: pre-commit:clean
+6
View File
@@ -330,6 +330,12 @@ tasks.register('cleanFrontendAssets', Delete) {
group = 'frontend'
description = 'Remove previously generated frontend assets from static resources'
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
// Prerendered per-route SPA pages (e.g. compress.html) carry per-tool OG tags and are
// copied from the frontend build. Remove stale ones so renamed/removed tools don't linger.
// api-landing.html is a real backend source file, not a generated artifact.
delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html'])
// Nested prerendered route pages (e.g. settings/people.html)
delete new File(resourcesStaticDir, 'settings')
}
tasks.register('copyApiLandingPage', Copy) {
@@ -2215,6 +2215,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-security-oauth2-resource-server",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "4.0.6",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-servlet",
"moduleUrl": "https://spring.io/projects/spring-boot",
@@ -2320,6 +2327,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-oauth2-resource-server",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "4.0.6",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-security",
"moduleUrl": "https://spring.io/projects/spring-boot",
@@ -2438,6 +2452,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-oauth2-resource-server",
"moduleUrl": "https://spring.io/projects/spring-security",
"moduleVersion": "7.0.5",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-saml2-service-provider",
"moduleUrl": "https://spring.io/projects/spring-security",
@@ -5,6 +5,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
@@ -27,6 +28,7 @@ import stirling.software.proprietary.policy.model.Policy;
* defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted.
*/
@Component
@Profile("saas")
public class FolderAccessGuard {
public static final String FOLDER_TYPE = "folder";
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.config;
import java.util.List;
import java.util.Objects;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -21,6 +22,7 @@ import stirling.software.proprietary.policy.model.Policy;
*/
@Component
@RequiredArgsConstructor
@Profile("saas")
public class PolicyAccessGuard {
private final UserServiceInterface userService;
@@ -6,6 +6,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
@@ -36,6 +37,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.job.JobResponse;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
@@ -63,6 +65,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
@Hidden
@RequiredArgsConstructor
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
@Profile("saas")
public class PolicyController {
private final PolicyRunner policyRunner;
@@ -73,6 +76,7 @@ public class PolicyController {
private final PolicyManagementAuthority policyManagementAuthority;
private final ApplicationProperties applicationProperties;
private final TempFileManager tempFileManager;
private final JobOwnershipService jobOwnershipService;
@PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
@@ -143,6 +147,35 @@ public class PolicyController {
return ResponseEntity.ok(PolicyRunView.of(run));
}
@GetMapping("/runs")
@Operation(
summary = "List the caller's stored-policy runs",
description =
"Returns the caller's in-flight and recently-finished stored-policy runs (within"
+ " the run-retention window). The frontend reconciles these on load so a"
+ " run started before a refresh/crash is rediscovered and its outputs"
+ " collected, rather than orphaned on the backend. Ad-hoc runs (no"
+ " policy id) are excluded.")
public List<PolicyRunView> listRuns() {
return runRegistry.all().stream()
.filter(run -> run.getPolicyId() != null)
.filter(run -> ownedByCurrentUser(run.getRunId()))
.map(PolicyRunView::of)
.toList();
}
/**
* Whether the run is owned by the current user, derived purely from the existing scoping
* methods: stripping then re-applying the scope reproduces the run's key only when its owner
* prefix matches the caller's. No auth (single-user) owns everything. Avoids duplicating the
* scoped-key format here.
*/
private boolean ownedByCurrentUser(String runId) {
return jobOwnershipService
.createScopedJobKey(jobOwnershipService.extractJobId(runId))
.equals(runId);
}
// --- Policy management ---
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@@ -9,6 +9,7 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import org.slf4j.MDC;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
@@ -53,6 +54,7 @@ import stirling.software.proprietary.service.DownstreamEntitlementError;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
public class PolicyEngine {
// Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate
@@ -81,13 +83,27 @@ public class PolicyEngine {
*/
public PolicyRunHandle submit(
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) {
return submit(definition, inputs, listener, null);
}
/**
* As {@link #submit(PipelineDefinition, PolicyInputs, PolicyProgressListener)}, recording the
* originating stored policy's id on the run ({@code null} for ad-hoc pipelines). The id lets a
* client attribute a run it rediscovers via {@code GET /policies/runs} after losing local state
* (e.g. a refresh before it recorded the run), so a finished run is never orphaned server-side.
*/
public PolicyRunHandle submit(
PipelineDefinition definition,
PolicyInputs inputs,
PolicyProgressListener listener,
String policyId) {
// Ad-hoc run (no stored policy): bill whoever kicked it off and own the outputs as them
// too.
// Capture the principal on this (request) thread — it does not survive the hop onto the
// async
// worker.
String principal = currentActingPrincipal();
return submitForPrincipal(principal, principal, definition, inputs, listener);
return submitForPrincipal(principal, principal, policyId, definition, inputs, listener);
}
/** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */
@@ -103,12 +119,13 @@ public class PolicyEngine {
String triggeringUser = currentActingPrincipal();
String fileOwner = triggeringUser != null ? triggeringUser : policy.owner();
return submitForPrincipal(
policy.owner(), fileOwner, policy.toDefinition(), inputs, listener);
policy.owner(), fileOwner, policy.id(), policy.toDefinition(), inputs, listener);
}
private PolicyRunHandle submitForPrincipal(
String billingPrincipal,
String fileOwner,
String policyId,
PipelineDefinition definition,
PolicyInputs inputs,
PolicyProgressListener listener) {
@@ -116,7 +133,7 @@ public class PolicyEngine {
// ownership check passes. No-op when security is off.
String runId = jobOwnershipService.createScopedJobKey(UUID.randomUUID().toString());
taskManager.createTask(runId);
PolicyRun run = new PolicyRun(runId, definition);
PolicyRun run = new PolicyRun(runId, policyId, definition);
registry.register(run);
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
PolicyProgressListener tracking = trackingListener(runId, run, listener);
@@ -9,6 +9,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import jakarta.annotation.PreDestroy;
@@ -28,6 +29,7 @@ import stirling.software.proprietary.policy.model.PolicyRun;
*/
@Slf4j
@Service
@Profile("saas")
public class PolicyRunRegistry {
private final Map<String, PolicyRun> runs = new ConcurrentHashMap<>();
@@ -4,6 +4,7 @@ import java.io.IOException;
import java.util.List;
import java.util.function.Consumer;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -26,6 +27,7 @@ import stirling.software.proprietary.policy.progress.PolicyProgressListener;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
public class PolicyRunner {
private final PolicyEngine policyEngine;
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.engine;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -21,6 +22,7 @@ import stirling.software.proprietary.policy.trigger.PolicyTrigger;
*/
@Service
@RequiredArgsConstructor
@Profile("saas")
public class PolicyValidator {
private final List<PolicyTrigger> triggers;
@@ -9,6 +9,7 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
@@ -33,6 +34,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
public class FolderInputSource implements InputSource {
private static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
@@ -17,6 +17,10 @@ import stirling.software.common.model.job.ResultFile;
public class PolicyRun {
private final String runId;
/** ID of the stored policy that produced this run; null for ad-hoc pipelines. */
private final String policyId;
private final PipelineDefinition definition;
private final Instant createdAt = Instant.now();
@@ -45,8 +49,9 @@ public class PolicyRun {
private volatile List<ResultFile> outputs = List.of();
private volatile Instant updatedAt = Instant.now();
public PolicyRun(String runId, PipelineDefinition definition) {
public PolicyRun(String runId, String policyId, PipelineDefinition definition) {
this.runId = runId;
this.policyId = policyId;
this.definition = definition;
}
@@ -10,23 +10,28 @@ import stirling.software.common.model.job.ResultFile;
*/
public record PolicyRunView(
String runId,
String policyId,
PolicyRunStatus status,
int currentStep,
int stepCount,
String error,
String errorCode,
Boolean errorSubscribed,
List<ResultFile> outputs) {
List<ResultFile> outputs,
/** When the run was created, epoch millis, so a rediscovered run shows its real age. */
long createdAt) {
public static PolicyRunView of(PolicyRun run) {
return new PolicyRunView(
run.getRunId(),
run.getPolicyId(),
run.getStatus(),
run.getCurrentStep(),
run.stepCount(),
run.getError(),
run.getErrorCode(),
run.getErrorSubscribed(),
run.getOutputs());
run.getOutputs(),
run.getCreatedAt().toEpochMilli());
}
}
@@ -9,6 +9,7 @@ import java.util.List;
import java.util.UUID;
import org.apache.commons.io.FilenameUtils;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -30,6 +31,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
public class FolderOutputSink implements PolicyOutputSink {
static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
@@ -5,6 +5,7 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -22,6 +23,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
*/
@Service
@RequiredArgsConstructor
@Profile("saas")
public class InlineOutputSink implements PolicyOutputSink {
private static final String TYPE = "inline";
@@ -4,6 +4,7 @@ import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -18,6 +19,7 @@ import tools.jackson.databind.ObjectMapper;
*/
@Service
@RequiredArgsConstructor
@Profile("saas")
public class JpaPolicyStore implements PolicyStore {
private final PolicyRepository repository;
@@ -20,6 +20,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -45,6 +46,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
public class FolderWatchTrigger implements PolicyTrigger {
private static final String TYPE = "folder-watch";
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.trigger;
import java.util.List;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -12,6 +13,7 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
public class PolicyTriggerManager implements SmartLifecycle {
private final List<PolicyTrigger> triggers;
@@ -10,6 +10,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -31,6 +32,7 @@ import tools.jackson.databind.ObjectMapper;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
public class ScheduleTrigger implements PolicyTrigger {
private static final String TYPE = "schedule";
@@ -978,28 +978,33 @@ public class UserController {
}
}
// Lists enabled users for the signing user picker, scoped by storage.signing.userListScope:
// 'org' (default) = whole instance, anything else = caller's team only (fail-closed).
// Lists enabled users for the signing picker; 'org' scope = instance-wide, else caller's team.
@GetMapping("/users")
public ResponseEntity<List<UserSummaryDTO>> listUsers(Principal principal) {
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Optional<User> callerOpt = userService.findByUsernameIgnoreCase(principal.getName());
// Anonymous (SaaS) accounts must never enumerate users, in any scope or team.
if (callerOpt.map(UserController::isAnonymousUser).orElse(false)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
// Fail-closed: only literal "org" opens the whole instance; anything else scopes to team.
String scope = applicationProperties.getStorage().getSigning().getUserListScope();
boolean teamScoped = !"org".equalsIgnoreCase(scope == null ? "" : scope.trim());
List<User> source;
if (teamScoped) {
Optional<User> callerOpt = userService.findByUsernameIgnoreCase(principal.getName());
if (callerOpt.isEmpty() || callerOpt.get().getTeam() == null) {
// No team: return only the caller rather than leak the org.
Team callerTeam = callerOpt.map(User::getTeam).orElse(null);
if (callerTeam == null || isSystemTeam(callerTeam)) {
// No team or a shared system team: return only the caller, not the team's members.
source = callerOpt.map(List::of).orElse(List.of());
} else {
// KNOWN LIMITATION: scopes the team via the single User.team FK - correct while
// acceptInvitation() collapses users to one team; revisit if multi-team enabled.
source = userRepository.findAllByTeamId(callerOpt.get().getTeam().getId());
// Scopes via the single User.team FK; revisit if multi-team membership is added.
source = userRepository.findAllByTeamId(callerTeam.getId());
}
} else {
source = userRepository.findAll();
@@ -1011,6 +1016,18 @@ public class UserController {
return ResponseEntity.ok(users);
}
// SaaS anonymous accounts, which must not enumerate users.
private static boolean isAnonymousUser(User user) {
return AuthenticationType.ANONYMOUS.name().equalsIgnoreCase(user.getAuthenticationType());
}
// System teams (Default/Internal) are not enumerable through the signing picker.
private static boolean isSystemTeam(Team team) {
String name = team.getName();
return TeamService.DEFAULT_TEAM_NAME.equalsIgnoreCase(name)
|| TeamService.INTERNAL_TEAM_NAME.equalsIgnoreCase(name);
}
private UserSummaryDTO toUserSummaryDTO(User user) {
return new UserSummaryDTO(
user.getId(),
@@ -105,15 +105,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
Stream<Long> findByUsernameIsNullAndCreatedAtBefore(
@Param("cutoffDate") LocalDateTime cutoffDate);
/** Users with an API key but no row in {@code user_credits}. */
@Query(
value =
"SELECT u.* FROM users u "
+ "LEFT JOIN user_credits uc ON uc.user_id = u.user_id "
+ "WHERE u.api_key IS NOT NULL AND uc.user_id IS NULL",
nativeQuery = true)
List<User> findUsersWithApiKeyButNoCredits();
/** Single-shot UPDATE that reassigns a user to a different team. */
@Modifying
@Query("UPDATE User u SET u.team.id = :teamId WHERE u.id = :userId")
@@ -95,7 +95,7 @@ class PolicyRunRegistryTest {
}
private PolicyRun register(String runId) {
PolicyRun run = new PolicyRun(runId, new PipelineDefinition(runId, List.of(), null));
PolicyRun run = new PolicyRun(runId, null, new PipelineDefinition(runId, List.of(), null));
registry.register(run);
return run;
}
@@ -2,7 +2,6 @@ package stirling.software.proprietary.security.controller.api;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -28,6 +27,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.repository.TeamRepository;
@@ -195,8 +195,22 @@ class UserControllerTest {
.andExpect(jsonPath("$[0].username").value("a@alpha.com"))
.andExpect(jsonPath("$[1].username").value("b@alpha.com"));
// Caller is resolved (for the anonymous-gate) but org scope still uses findAll, not team.
verify(userRepository, never()).findAllByTeamId(any());
}
@Test
void listUsersForbiddenForAnonymousCaller() throws Exception {
// Anonymous SaaS accounts must never enumerate users, regardless of scope.
User anon = user(1L, "anon_abc", true, team(1L, TeamService.DEFAULT_TEAM_NAME));
anon.setAuthenticationType(AuthenticationType.ANONYMOUS);
when(userService.findByUsernameIgnoreCase("anon_abc")).thenReturn(Optional.of(anon));
mockMvc.perform(get("/api/v1/user/users").principal(auth("anon_abc")))
.andExpect(status().isForbidden());
verify(userRepository, never()).findAll();
verify(userRepository, never()).findAllByTeamId(any());
verify(userService, never()).findByUsernameIgnoreCase(anyString());
}
@Test
@@ -262,6 +276,39 @@ class UserControllerTest {
verify(userRepository, never()).findAll();
}
@Test
void listUsersTeamScopeOnDefaultTeamReturnsSelfOnly() throws Exception {
// A caller on a shared system team must not enumerate its members.
applicationProperties.getStorage().getSigning().setUserListScope("team");
Team defaultTeam = team(1L, TeamService.DEFAULT_TEAM_NAME);
User caller = user(1L, "new@saas.com", true, defaultTeam);
when(userService.findByUsernameIgnoreCase("new@saas.com")).thenReturn(Optional.of(caller));
mockMvc.perform(get("/api/v1/user/users").principal(auth("new@saas.com")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].username").value("new@saas.com"));
verify(userRepository, never()).findAllByTeamId(any());
verify(userRepository, never()).findAll();
}
@Test
void listUsersTeamScopeOnInternalTeamReturnsSelfOnly() throws Exception {
applicationProperties.getStorage().getSigning().setUserListScope("team");
Team internalTeam = team(2L, TeamService.INTERNAL_TEAM_NAME);
User caller = user(1L, "svc@saas.com", true, internalTeam);
when(userService.findByUsernameIgnoreCase("svc@saas.com")).thenReturn(Optional.of(caller));
mockMvc.perform(get("/api/v1/user/users").principal(auth("svc@saas.com")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].username").value("svc@saas.com"));
verify(userRepository, never()).findAllByTeamId(any());
verify(userRepository, never()).findAll();
}
@Test
void listUsersFailsClosedOnUnrecognisedScope() throws Exception {
// Any non-"org" value must restrict to the caller's team, not leak the instance.
@@ -51,10 +51,7 @@ import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.ProcessType;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
import stirling.software.saas.util.CreditHeaderUtils;
@RestController
@Profile("saas")
@@ -69,10 +66,7 @@ public class AiCreateController {
private final AiCreateSessionService sessionService;
private final AiCreateProxyService proxyService;
private final ObjectMapper objectMapper = new ObjectMapper();
private final CreditService creditService;
private final TeamCreditService teamCreditService;
private final UserRepository userRepository;
private final CreditHeaderUtils creditHeaderUtils;
private final JobChargeService jobChargeService;
@PostMapping("/sessions")
@@ -233,8 +227,7 @@ public class AiCreateController {
@PathVariable String sessionId, HttpServletRequest request) {
sessionService.getSessionForCurrentUser(sessionId);
log.info("AI create fillFields sessionId={}", sessionId);
return proxy(
"POST", "/api/create/sessions/" + sessionId + "/fields", request, false, false);
return proxy("POST", "/api/create/sessions/" + sessionId + "/fields", request, false);
}
@GetMapping(
@@ -243,20 +236,11 @@ public class AiCreateController {
public ResponseEntity<StreamingResponseBody> stream(
@PathVariable String sessionId, HttpServletRequest request) {
sessionService.getSessionForCurrentUser(sessionId);
return proxy(
"GET",
"/api/create/sessions/" + sessionId + "/stream",
request,
true,
true); // Add credits header: frontend endpoint that triggers AI
return proxy("GET", "/api/create/sessions/" + sessionId + "/stream", request, true);
}
private ResponseEntity<StreamingResponseBody> proxy(
String method,
String path,
HttpServletRequest request,
boolean acceptEventStream,
boolean includeCreditsHeader) {
String method, String path, HttpServletRequest request, boolean acceptEventStream) {
try {
HttpResponse<InputStream> response =
proxyService.forward(method, path, request, acceptEventStream);
@@ -270,11 +254,6 @@ public class AiCreateController {
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE);
}
// Add credit headers if requested
if (includeCreditsHeader) {
addCreditHeaders(headers);
}
StreamingResponseBody body =
outputStream -> {
try (InputStream inputStream = response.body()) {
@@ -302,31 +281,6 @@ public class AiCreateController {
.ifPresent(value -> headers.set(headerName, value));
}
/**
* Add credit headers to the response headers.
*
* @param headers The headers to add credit information to
*/
private void addCreditHeaders(HttpHeaders headers) {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
log.debug("[AI-CREATE] No authentication found, skipping credit header");
return;
}
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
int remainingCredits =
creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService);
if (remainingCredits >= 0) {
headers.set("X-Credits-Remaining", Integer.toString(remainingCredits));
log.warn("[AI-CREATE] Added X-Credits-Remaining header: {}", remainingCredits);
}
} catch (Exception e) {
log.error("[AI-CREATE] Failed to add credit header: {}", e.getMessage(), e);
}
}
public record CreateSessionRequest(
String prompt,
String docType,
@@ -9,8 +9,6 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -25,15 +23,9 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.ai.service.AiProxyService;
import stirling.software.saas.payg.cap.RequiresFeature;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
import stirling.software.saas.util.CreditHeaderUtils;
@RestController
@Profile("saas")
@@ -45,103 +37,89 @@ import stirling.software.saas.util.CreditHeaderUtils;
public class AiProxyController {
private final AiProxyService aiProxyService;
private final CreditService creditService;
private final TeamCreditService teamCreditService;
private final UserRepository userRepository;
private final CreditHeaderUtils creditHeaderUtils;
public AiProxyController(
AiProxyService aiProxyService,
CreditService creditService,
TeamCreditService teamCreditService,
UserRepository userRepository,
CreditHeaderUtils creditHeaderUtils) {
public AiProxyController(AiProxyService aiProxyService) {
this.aiProxyService = aiProxyService;
this.creditService = creditService;
this.teamCreditService = teamCreditService;
this.userRepository = userRepository;
this.creditHeaderUtils = creditHeaderUtils;
}
@PostMapping("/generate_section")
public ResponseEntity<StreamingResponseBody> generateSection(HttpServletRequest request) {
return proxy("POST", "/api/generate_section", request, false, false);
return proxy("POST", "/api/generate_section", request, false);
}
@PostMapping("/generate_all_sections")
public ResponseEntity<StreamingResponseBody> generateAllSections(HttpServletRequest request) {
return proxy("POST", "/api/generate_all_sections", request, false, false);
return proxy("POST", "/api/generate_all_sections", request, false);
}
@PostMapping("/intent/check")
public ResponseEntity<StreamingResponseBody> intentCheck(HttpServletRequest request) {
return proxy("POST", "/api/intent/check", request, false, false);
return proxy("POST", "/api/intent/check", request, false);
}
@PostMapping("/chat/route")
public ResponseEntity<StreamingResponseBody> chatRoute(HttpServletRequest request) {
return proxy("POST", "/api/chat/route", request, false, true);
return proxy("POST", "/api/chat/route", request, false);
}
@PostMapping("/chat/create-smart-folder")
public ResponseEntity<StreamingResponseBody> createSmartFolder(HttpServletRequest request) {
return proxy("POST", "/api/chat/create-smart-folder", request, false, true);
return proxy("POST", "/api/chat/create-smart-folder", request, false);
}
@PostMapping("/chat/info")
public ResponseEntity<StreamingResponseBody> chatInfo(HttpServletRequest request) {
return proxy("POST", "/api/chat/info", request, false, true);
return proxy("POST", "/api/chat/info", request, false);
}
@PostMapping("/pdf/answer")
public ResponseEntity<StreamingResponseBody> pdfAnswer(HttpServletRequest request) {
return proxy("POST", "/api/pdf/answer", request, false, false);
return proxy("POST", "/api/pdf/answer", request, false);
}
@PostMapping("/progressive_render")
public ResponseEntity<StreamingResponseBody> progressiveRender(HttpServletRequest request) {
return proxy("POST", "/api/progressive_render", request, false, false);
return proxy("POST", "/api/progressive_render", request, false);
}
@GetMapping("/versions/{userId}")
public ResponseEntity<StreamingResponseBody> versions(
@PathVariable("userId") String userId, HttpServletRequest request) {
return proxy("GET", "/api/versions/" + userId, request, false, false);
return proxy("GET", "/api/versions/" + userId, request, false);
}
@GetMapping("/style/{userId}")
public ResponseEntity<StreamingResponseBody> style(
@PathVariable("userId") String userId, HttpServletRequest request) {
return proxy("GET", "/api/style/" + userId, request, false, false);
return proxy("GET", "/api/style/" + userId, request, false);
}
@PostMapping("/style/{userId}")
public ResponseEntity<StreamingResponseBody> updateStyle(
@PathVariable("userId") String userId, HttpServletRequest request) {
return proxy("POST", "/api/style/" + userId, request, false, false);
return proxy("POST", "/api/style/" + userId, request, false);
}
@PostMapping("/import_template")
public ResponseEntity<StreamingResponseBody> importTemplate(HttpServletRequest request) {
return proxy("POST", "/api/import_template", request, false, false);
return proxy("POST", "/api/import_template", request, false);
}
@PostMapping("/edit/sessions")
public ResponseEntity<StreamingResponseBody> createEditSession(HttpServletRequest request) {
return proxy("POST", "/api/edit/sessions", request, false, false);
return proxy("POST", "/api/edit/sessions", request, false);
}
@PostMapping("/edit/sessions/{sessionId}/messages")
public ResponseEntity<StreamingResponseBody> editSessionMessage(
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
return proxy("POST", "/api/edit/sessions/" + sessionId + "/messages", request, false, true);
return proxy("POST", "/api/edit/sessions/" + sessionId + "/messages", request, false);
}
@PostMapping("/edit/sessions/{sessionId}/attachments")
public ResponseEntity<StreamingResponseBody> editSessionAttachment(
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
return proxy(
"POST", "/api/edit/sessions/" + sessionId + "/attachments", request, false, false);
return proxy("POST", "/api/edit/sessions/" + sessionId + "/attachments", request, false);
}
@PostMapping(
@@ -149,17 +127,17 @@ public class AiProxyController {
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<StreamingResponseBody> runEditSession(
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
return proxy("POST", "/api/edit/sessions/" + sessionId + "/run", request, true, false);
return proxy("POST", "/api/edit/sessions/" + sessionId + "/run", request, true);
}
@GetMapping("/pdf-editor/document")
public ResponseEntity<StreamingResponseBody> pdfEditorDocument(HttpServletRequest request) {
return proxy("GET", "/api/pdf-editor/document", request, false, false);
return proxy("GET", "/api/pdf-editor/document", request, false);
}
@PostMapping("/pdf-editor/upload")
public ResponseEntity<StreamingResponseBody> pdfEditorUpload(HttpServletRequest request) {
return proxy("POST", "/api/pdf-editor/upload", request, false, false);
return proxy("POST", "/api/pdf-editor/upload", request, false);
}
@GetMapping("/output/**")
@@ -167,27 +145,22 @@ public class AiProxyController {
String requestUri = request.getRequestURI();
String prefix = request.getContextPath() + "/api/v1/ai/output/";
String path = requestUri.startsWith(prefix) ? requestUri.substring(prefix.length()) : "";
return proxy("GET", "/output/" + path, request, false, false);
return proxy("GET", "/output/" + path, request, false);
}
// Health endpoint at /api/v1/ai/health is owned by the proprietary AiEngineController; both
// proxy to the same backing AI engine. No need for credit-aware wrapping on a health probe.
/**
* Proxy method that optionally adds credit headers.
* Proxy method.
*
* @param method HTTP method
* @param path API path
* @param request The incoming request
* @param acceptEventStream Whether to accept event stream responses
* @param includeCreditsHeader Whether to add credit balance header
*/
private ResponseEntity<StreamingResponseBody> proxy(
String method,
String path,
HttpServletRequest request,
boolean acceptEventStream,
boolean includeCreditsHeader) {
String method, String path, HttpServletRequest request, boolean acceptEventStream) {
try {
// Forward to AI backend
HttpResponse<InputStream> aiResponse =
@@ -204,11 +177,6 @@ public class AiProxyController {
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE);
}
// Add credit headers if requested (after AI processing completes)
if (includeCreditsHeader) {
addCreditHeaders(headers);
}
StreamingResponseBody body =
outputStream -> {
try (InputStream inputStream = aiResponse.body()) {
@@ -247,30 +215,4 @@ public class AiProxyController {
}
});
}
/**
* Add credit headers to the response headers.
*
* @param headers The headers to add credit information to
*/
private void addCreditHeaders(HttpHeaders headers) {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
log.debug("[AI-PROXY] No authentication found, skipping credit header");
return;
}
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
int remainingCredits =
creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService);
if (remainingCredits >= 0) {
headers.set("X-Credits-Remaining", Integer.toString(remainingCredits));
log.warn("[AI-PROXY] Added X-Credits-Remaining header: {}", remainingCredits);
}
headers.set("X-Credit-Source", "AI_TOOL_CALL");
} catch (Exception e) {
log.error("[AI-PROXY] Failed to add credit header: {}", e.getMessage(), e);
}
}
}
@@ -1,31 +0,0 @@
package stirling.software.saas.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
import stirling.software.saas.interceptor.UnifiedCreditInterceptor;
// Legacy credit-billing path. Disabled in saas-PAYG by default — activate the legacy-credits
// profile explicitly (`--spring.profiles.active=saas,dev,legacy-credits`) if you need it back.
@Configuration
@Profile("saas & legacy-credits")
@RequiredArgsConstructor
public class CreditInterceptorConfig implements WebMvcConfigurer {
private final UnifiedCreditInterceptor unifiedCreditInterceptor;
private final CreditsProperties creditsProperties;
@Override
public void addInterceptors(InterceptorRegistry registry) {
if (creditsProperties.isEnabled()) {
registry.addInterceptor(unifiedCreditInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns(
"/api/v1/credits/**", "/api/v1/config/**", "/api/v1/info/**");
}
}
}
@@ -1,75 +0,0 @@
package stirling.software.saas.config;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import lombok.Data;
@Data
@Component
@Profile("saas")
@ConfigurationProperties(prefix = "credits")
public class CreditsProperties {
/** Whether the credits system is enabled */
private boolean enabled = true;
/** Credit allocations per billing cycle (monthly) */
private CycleAllocations cycle = new CycleAllocations();
/** Reset configuration */
private Reset reset = new Reset();
/** Error tracking configuration */
private Errors errors = new Errors();
/** Cache configuration */
private Cache cache = new Cache();
@Data
public static class CycleAllocations {
/** Whether admin role has unlimited credits */
private boolean adminUnlimited = true;
/** Credit allocations per billing cycle (monthly) per role */
private Map<String, Integer> allocations =
Map.of(
"ROLE_ADMIN", 1000,
"ROLE_PRO_USER", 500,
"ROLE_USER", 50,
"ROLE_LIMITED_API_USER", 10,
"ROLE_EXTRA_LIMITED_API_USER", 20,
"ROLE_WEB_ONLY_USER", 0,
"ROLE_DEMO_USER", 100);
}
@Data
public static class Reset {
/** Cron expression for monthly reset (default: 1st of month 02:00 UTC) */
private String cron = "0 0 2 1 * *";
/** Time zone for the reset schedule */
private String zone = "UTC";
}
@Data
public static class Errors {
/** How long error counts are tracked (in minutes) */
private int ttlMinutes = 60;
/** Number of free processing errors before charging */
private int freeProcessingErrors = 2;
}
@Data
public static class Cache {
/** Enable local Caffeine cache for error counts */
private boolean localEnabled = true;
/** Enable Redis cache for multi-instance deployments */
private boolean redisEnabled = false;
}
}
@@ -1,315 +0,0 @@
package stirling.software.saas.controller;
import java.util.Map;
import org.springframework.context.annotation.Profile;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.security.EnhancedJwtAuthenticationToken;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.CreditService.CreditSummary;
import stirling.software.saas.util.LogRedactionUtils;
// Legacy credit-billing endpoints. PAYG replaces this — gated behind legacy-credits profile.
@RestController
@Profile("saas & legacy-credits")
@RequestMapping("/api/v1/credits")
@Tag(name = "Credit Management", description = "Endpoints for managing user API credits")
@RequiredArgsConstructor
@Slf4j
public class CreditController {
private final CreditService creditService;
@GetMapping
@Hidden
@Operation(
summary = "Get user credit information",
description =
"Retrieve current credit balance and usage statistics for the authenticated user")
@ApiResponse(
responseCode = "200",
description = "Credit information retrieved successfully",
content = @Content(schema = @Schema(implementation = CreditSummary.class)))
public ResponseEntity<CreditSummary> getUserCredits(Authentication authentication) {
return ResponseEntity.ok(getCreditSummaryForAuthentication(authentication));
}
@PostMapping("/purchase")
@Hidden
@Operation(
summary = "Purchase additional credits",
description = "Add bought credits to user account (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Credits purchased successfully")
public ResponseEntity<Map<String, Object>> purchaseCredits(
@RequestParam("username") String username, @RequestParam("credits") int credits) {
if (credits <= 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits must be positive"));
}
try {
creditService.addBoughtCredits(username, credits);
log.info("Admin added {} credits to user: {}", credits, username);
return ResponseEntity.ok(Map.of("success", true, "creditsAdded", credits));
} catch (IllegalArgumentException e) {
log.warn("purchaseCredits rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@PostMapping("/purchase-by-supabase-id")
@Hidden
@Operation(
summary = "Purchase additional credits by Supabase ID",
description = "Add bought credits to user account using Supabase ID (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Credits purchased successfully")
public ResponseEntity<Map<String, Object>> purchaseCreditsBySupabaseId(
@RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) {
if (credits <= 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits must be positive"));
}
try {
creditService.addBoughtCreditsBySupabaseId(supabaseId, credits);
log.info(
"Admin added {} credits to user with Supabase ID: {}",
credits,
LogRedactionUtils.redactSupabaseId(supabaseId));
return ResponseEntity.ok(Map.of("success", true, "creditsAdded", credits));
} catch (IllegalArgumentException e) {
log.warn("purchaseCreditsBySupabaseId rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@GetMapping("/user/{username}")
@Hidden
@Operation(
summary = "Get credit information for specific user",
description = "Retrieve credit information for a specific user (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(
responseCode = "200",
description = "User credit information retrieved successfully",
content = @Content(schema = @Schema(implementation = CreditSummary.class)))
public ResponseEntity<CreditSummary> getUserCreditsAdmin(
@PathVariable("username") String username) {
CreditSummary summary = creditService.getCreditSummary(username);
return ResponseEntity.ok(summary);
}
@GetMapping("/user-by-supabase-id/{supabaseId}")
@Hidden
@Operation(
summary = "Get credit information for specific user by Supabase ID",
description =
"Retrieve credit information for a specific user using Supabase ID (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(
responseCode = "200",
description = "User credit information retrieved successfully",
content = @Content(schema = @Schema(implementation = CreditSummary.class)))
public ResponseEntity<CreditSummary> getUserCreditsAdminBySupabaseId(
@PathVariable("supabaseId") String supabaseId) {
CreditSummary summary = creditService.getCreditSummaryBySupabaseId(supabaseId);
return ResponseEntity.ok(summary);
}
@PostMapping("/reset-cycle")
@Hidden
@Operation(
summary = "Reset cycle credits for all users",
description = "Manually trigger cycle credit reset for all users (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Cycle credits reset successfully")
public ResponseEntity<String> resetCycleCredits() {
creditService.resetCycleCreditsForAllUsers();
log.info("Manual cycle credit reset triggered by admin");
return ResponseEntity.ok("Cycle credits reset successfully for all users");
}
@PostMapping("/set-bought-credits")
@Hidden
@Operation(
summary = "Set user's bought credits to a specific amount",
description =
"Hard set the bought credits balance for a specific user to an exact amount (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Bought credits set successfully")
public ResponseEntity<Map<String, Object>> setBoughtCredits(
@RequestParam("username") String username, @RequestParam("credits") int credits) {
if (credits < 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
}
try {
creditService.setBoughtCredits(username, credits);
log.info("Admin set bought credits to {} for user: {}", credits, username);
return ResponseEntity.ok(Map.of("success", true, "boughtCredits", credits));
} catch (IllegalArgumentException e) {
log.warn("setBoughtCredits rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@PostMapping("/set-bought-credits-by-supabase-id")
@Hidden
@Operation(
summary = "Set user's bought credits to a specific amount by Supabase ID",
description =
"Hard set the bought credits balance for a specific user using Supabase ID to an exact amount (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Bought credits set successfully")
public ResponseEntity<Map<String, Object>> setBoughtCreditsBySupabaseId(
@RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) {
if (credits < 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
}
try {
creditService.setBoughtCreditsBySupabaseId(supabaseId, credits);
log.info(
"Admin set bought credits to {} for user with Supabase ID: {}",
credits,
LogRedactionUtils.redactSupabaseId(supabaseId));
return ResponseEntity.ok(Map.of("success", true, "boughtCredits", credits));
} catch (IllegalArgumentException e) {
log.warn("setBoughtCreditsBySupabaseId rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@PostMapping("/set-cycle-credits")
@Hidden
@Operation(
summary = "Set user's cycle credits remaining to a specific amount",
description =
"Hard set the cycle credits remaining balance for a specific user to an exact amount (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Cycle credits set successfully")
public ResponseEntity<Map<String, Object>> setCycleCredits(
@RequestParam("username") String username, @RequestParam("credits") int credits) {
if (credits < 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
}
try {
creditService.setCycleCredits(username, credits);
log.info("Admin set cycle credits to {} for user: {}", credits, username);
return ResponseEntity.ok(Map.of("success", true, "cycleCredits", credits));
} catch (IllegalArgumentException e) {
log.warn("setCycleCredits rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@PostMapping("/set-cycle-credits-by-supabase-id")
@Hidden
@Operation(
summary = "Set user's cycle credits remaining to a specific amount by Supabase ID",
description =
"Hard set the cycle credits remaining balance for a specific user using Supabase ID to an exact amount (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Cycle credits set successfully")
public ResponseEntity<Map<String, Object>> setCycleCreditsBySupabaseId(
@RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) {
if (credits < 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
}
try {
creditService.setCycleCreditsBySupabaseId(supabaseId, credits);
log.info(
"Admin set cycle credits to {} for user with Supabase ID: {}",
credits,
LogRedactionUtils.redactSupabaseId(supabaseId));
return ResponseEntity.ok(Map.of("success", true, "cycleCredits", credits));
} catch (IllegalArgumentException e) {
log.warn("setCycleCreditsBySupabaseId rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@GetMapping("/usage")
@Hidden
@Operation(
summary = "Get credit usage summary",
description = "Get overview of credit usage (for authenticated user or admin view)")
public ResponseEntity<UsageSummary> getCreditUsage(Authentication authentication) {
CreditSummary summary = getCreditSummaryForAuthentication(authentication);
// For unlimited users, don't show meaningless huge usage numbers
int cycleCreditsUsed =
summary.unlimited
? 0
: (summary.cycleCreditsAllocated - summary.cycleCreditsRemaining);
UsageSummary usage =
new UsageSummary(
cycleCreditsUsed,
summary.totalBoughtCredits - summary.boughtCreditsRemaining,
summary.totalAvailableCredits,
summary.unlimited);
return ResponseEntity.ok(usage);
}
/** Resolves the current authentication to a credit summary, handling JWT and API-key auth. */
private CreditSummary getCreditSummaryForAuthentication(Authentication authentication) {
if (authentication instanceof EnhancedJwtAuthenticationToken enhancedJwt) {
return creditService.getCreditSummaryBySupabaseId(enhancedJwt.getSupabaseId());
}
if (authentication instanceof ApiKeyAuthenticationToken apiKeyToken) {
String apiKey = (String) apiKeyToken.getCredentials();
// Principal is the resolved User entity (per SupabaseAuthenticationFilter). Prefer the
// linked Supabase ID; fall back to API-key-keyed credits if there's no supabase link
// or no User row (e.g. legacy API-key-only deployments).
if (apiKeyToken.getPrincipal() instanceof User user && user.getSupabaseId() != null) {
return creditService.getCreditSummaryBySupabaseId(user.getSupabaseId().toString());
}
return creditService.getCreditSummaryByApiKey(apiKey);
}
return creditService.getCreditSummaryBySupabaseId(authentication.getName());
}
public static class UsageSummary {
public final int cycleCreditsUsed;
public final int boughtCreditsUsed;
public final int creditsRemaining;
public final boolean unlimited;
public UsageSummary(
int cycleCreditsUsed,
int boughtCreditsUsed,
int creditsRemaining,
boolean unlimited) {
this.cycleCreditsUsed = cycleCreditsUsed;
this.boughtCreditsUsed = boughtCreditsUsed;
this.creditsRemaining = creditsRemaining;
this.unlimited = unlimited;
}
}
}
@@ -1,307 +0,0 @@
package stirling.software.saas.interceptor;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.CreditConsumptionResult;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.ErrorTrackingService;
import stirling.software.saas.service.SaasTeamExtensionService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
import stirling.software.saas.util.CreditHeaderUtils;
/**
* Scoped to controllers annotated with {@link AutoJobPostMapping} so it doesn't hijack the global
* exception flow.
*/
// Legacy credit-billing error advice. PAYG handles its own error semantics via
// PaygChargeInterceptor — disabled by default in saas, activate legacy-credits profile if needed.
@RestControllerAdvice(annotations = AutoJobPostMapping.class)
@Profile("saas & legacy-credits")
@Slf4j
@Order(1)
public class CreditErrorAdvice {
private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE";
private static final String ATTR_APIKEY = "CREDIT_API_KEY";
private static final String ATTR_CHARGED = "CREDIT_CHARGED";
private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
private final CreditService creditService;
private final TeamCreditService teamCreditService;
private final UserRepository userRepository;
private final ErrorTrackingService errorTrackingService;
private final SaasTeamExtensionService saasTeamExtensionService;
private final CreditHeaderUtils creditHeaderUtils;
private final Counter creditsConsumedCounter;
// Inlined: Stirling's parent build uses Jackson 3 (tools.jackson), no Jackson 2 ObjectMapper
// bean in the context. Stateless usage, so a fresh instance is fine.
private final ObjectMapper objectMapper = new ObjectMapper();
public CreditErrorAdvice(
CreditService creditService,
TeamCreditService teamCreditService,
UserRepository userRepository,
ErrorTrackingService errorTrackingService,
SaasTeamExtensionService saasTeamExtensionService,
CreditHeaderUtils creditHeaderUtils,
MeterRegistry meterRegistry) {
this.creditService = creditService;
this.teamCreditService = teamCreditService;
this.userRepository = userRepository;
this.errorTrackingService = errorTrackingService;
this.saasTeamExtensionService = saasTeamExtensionService;
this.creditHeaderUtils = creditHeaderUtils;
this.creditsConsumedCounter =
Counter.builder("credits.consumed")
.description("Number of credits actually consumed")
.tag("source", "error")
.register(meterRegistry);
}
@ExceptionHandler(Throwable.class)
public ResponseEntity<Object> handleThrowable(HttpServletRequest request, Throwable ex) {
HttpStatus status = determineHttpStatus(ex);
log.debug(
"[CREDIT-DEBUG] CreditErrorAdvice: Handling exception: {} -> {}",
ex.getClass().getSimpleName(),
status);
String message = Optional.ofNullable(ex.getMessage()).orElse("An error occurred");
// Build error body
Map<String, Object> body = new HashMap<>();
body.put("error", ex.getClass().getSimpleName());
body.put("message", message);
body.put("status", status.value());
var builder = ResponseEntity.status(status);
// Handle credit consumption for errors
if (Boolean.TRUE.equals(request.getAttribute(ATTR_ELIGIBLE))
&& request.getAttribute(ATTR_CHARGED) == null) {
var apiKey = (String) request.getAttribute(ATTR_APIKEY);
var resourceWeight = (Integer) request.getAttribute(ATTR_RESOURCE_WEIGHT);
var isApiRequest = (Boolean) request.getAttribute("IS_API_REQUEST");
int creditAmount = resourceWeight != null ? resourceWeight : 1;
String identifierForErrorTracking =
apiKey; // Keep using apiKey/username for error tracking
if (apiKey != null
&& errorTrackingService.recordErrorAndShouldConsumeCredit(
identifierForErrorTracking,
request.getRequestURI(),
ex,
status.value())) {
// Get current user
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
User user = null;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (Exception e) {
log.warn(
"[CREDIT-DEBUG] CreditErrorAdvice: Could not get user for team check: {}",
e.getMessage());
}
if (user == null) {
log.error(
"[CREDIT-DEBUG] CreditErrorAdvice: Unable to resolve user - skipping credit consumption");
} else {
// Check if user is in a non-personal team (must match UnifiedCreditInterceptor
// logic)
Long targetTeamId = null;
if (user.getTeam() != null
&& !saasTeamExtensionService.isPersonal(user.getTeam())) {
targetTeamId = user.getTeam().getId();
}
boolean consumed = false;
String creditSource = null;
if (targetTeamId != null) {
// User is in a non-personal team - consume from team credit pool
consumed = teamCreditService.consumeCredit(targetTeamId, creditAmount);
creditSource = "TEAM_CREDITS";
log.debug(
"[CREDIT-DEBUG] CreditErrorAdvice: Consumed {} credits from team {}",
creditAmount,
targetTeamId);
} else {
// No team - use waterfall logic for individual credits
boolean isApiRequestFlag = Boolean.TRUE.equals(isApiRequest);
CreditConsumptionResult result =
creditService.consumeCreditWithWaterfall(
user, creditAmount, isApiRequestFlag);
consumed = result.isSuccess();
creditSource = result.getSource();
if (!consumed) {
log.error(
"[CREDIT-DEBUG] CreditErrorAdvice: Credit consumption failed for user: {} - {}",
user.getUsername(),
result.getMessage());
}
}
if (consumed) {
request.setAttribute(ATTR_CHARGED, Boolean.TRUE);
creditsConsumedCounter.increment();
// Set remaining credits header
int remainingCredits =
creditHeaderUtils.getRemainingCredits(
user, creditService, teamCreditService);
if (remainingCredits >= 0) {
builder.header(
"X-Credits-Remaining", Integer.toString(remainingCredits));
log.warn(
"[CREDIT-HEADER] Added X-Credits-Remaining header: {}",
remainingCredits);
}
if (creditSource != null) {
builder.header("X-Credit-Source", creditSource);
}
log.info(
"[CREDIT-DEBUG] CreditErrorAdvice: {} credits consumed from {} for user: {} (error case)",
creditAmount,
creditSource,
user.getUsername());
}
}
} else {
log.debug(
"[CREDIT-DEBUG] CreditErrorAdvice: ErrorTrackingService says do NOT consume credit for this error");
}
} else if (request.getAttribute(ATTR_CHARGED) != null) {
// Already charged, set header if user is authenticated
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated()) {
try {
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
int remainingCredits =
creditHeaderUtils.getRemainingCredits(
user, creditService, teamCreditService);
if (remainingCredits >= 0) {
builder.header("X-Credits-Remaining", Integer.toString(remainingCredits));
log.warn(
"[CREDIT-HEADER] Added X-Credits-Remaining header: {}",
remainingCredits);
}
} catch (Exception e) {
log.debug(
"[CREDIT-HEADER] Could not add credits header for already charged error: {}",
e.getMessage());
}
}
log.debug("[CREDIT-DEBUG] CreditErrorAdvice: Header set for already charged error");
}
if (isSseRequest(request)) {
String payload = toJsonPayload(body);
String sseBody = "event: error\ndata: " + payload + "\n\n";
return builder.contentType(MediaType.TEXT_EVENT_STREAM).body(sseBody);
}
return builder.body(body);
}
private String maskApiKey(String apiKey) {
if (apiKey == null || apiKey.length() < 8) {
return "***";
}
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
}
private HttpStatus determineHttpStatus(Throwable throwable) {
// Map common exceptions to HTTP status codes
String exceptionClass = throwable.getClass().getSimpleName();
switch (exceptionClass) {
case "IllegalArgumentException":
case "ValidationException":
case "MethodArgumentNotValidException":
return HttpStatus.BAD_REQUEST;
case "AccessDeniedException":
return HttpStatus.FORBIDDEN;
case "UsernameNotFoundException":
return HttpStatus.UNAUTHORIZED;
case "HttpMessageNotReadableException":
return HttpStatus.BAD_REQUEST;
case "MaxUploadSizeExceededException":
return HttpStatus.PAYLOAD_TOO_LARGE;
case "UnsupportedOperationException":
return HttpStatus.NOT_IMPLEMENTED;
default:
// Check error message for clues
String message = throwable.getMessage();
if (message != null) {
if (message.toLowerCase().contains("validation")
|| message.toLowerCase().contains("invalid parameter")) {
return HttpStatus.BAD_REQUEST;
}
if (message.toLowerCase().contains("not found")) {
return HttpStatus.NOT_FOUND;
}
}
return HttpStatus.INTERNAL_SERVER_ERROR;
}
}
private boolean isSseRequest(HttpServletRequest request) {
String accept = request.getHeader("Accept");
if (accept != null && accept.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) {
return true;
}
String contentType = request.getContentType();
return contentType != null && contentType.contains(MediaType.TEXT_EVENT_STREAM_VALUE);
}
private String toJsonPayload(Map<String, Object> payload) {
try {
return objectMapper.writeValueAsString(payload);
} catch (JsonProcessingException exc) {
log.warn("Failed to serialize SSE error payload, falling back to string", exc);
String message = payload.getOrDefault("message", "An error occurred").toString();
return "{\"error\":\"Error\",\"message\":\"" + message + "\",\"status\":500}";
}
}
public static class ErrorResponse {
public final String error;
public final String message;
public final int status;
public ErrorResponse(String error, String message, int status) {
this.error = error;
this.message = message;
this.status = status;
}
}
}
@@ -1,228 +0,0 @@
package stirling.software.saas.interceptor;
import org.springframework.context.annotation.Profile;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.CreditConsumptionResult;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.SaasTeamExtensionService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
import stirling.software.saas.util.CreditHeaderUtils;
// Legacy credit-billing success advice. PAYG writes its own ledger entries via
// JobChargeService — disabled by default in saas, activate legacy-credits profile if needed.
@RestControllerAdvice
@Profile("saas & legacy-credits")
@Slf4j
public class CreditSuccessAdvice implements ResponseBodyAdvice<Object> {
private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE";
private static final String ATTR_APIKEY = "CREDIT_API_KEY";
private static final String ATTR_CHARGED = "CREDIT_CHARGED";
private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
private final CreditService creditService;
private final TeamCreditService teamCreditService;
private final UserRepository userRepository;
private final SaasTeamExtensionService saasTeamExtensionService;
private final CreditHeaderUtils creditHeaderUtils;
private final Counter creditsConsumedCounter;
public CreditSuccessAdvice(
CreditService creditService,
TeamCreditService teamCreditService,
UserRepository userRepository,
SaasTeamExtensionService saasTeamExtensionService,
CreditHeaderUtils creditHeaderUtils,
MeterRegistry meterRegistry) {
this.creditService = creditService;
this.teamCreditService = teamCreditService;
this.userRepository = userRepository;
this.saasTeamExtensionService = saasTeamExtensionService;
this.creditHeaderUtils = creditHeaderUtils;
this.creditsConsumedCounter =
Counter.builder("credits.consumed")
.description("Number of credits actually consumed")
.tag("source", "success")
.register(meterRegistry);
}
@Override
public boolean supports(
MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// Only REST bodies; this covers @ResponseBody and ResponseEntity
return true;
}
@Override
public Object beforeBodyWrite(
Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
if (!(request instanceof ServletServerHttpRequest)) {
return body;
}
var servletReq = ((ServletServerHttpRequest) request).getServletRequest();
if (!Boolean.TRUE.equals(servletReq.getAttribute(ATTR_ELIGIBLE))) {
return body;
}
if (servletReq.getAttribute(ATTR_CHARGED) != null) {
return body;
}
// If the handler returned an error ResponseEntity (>=400) without throwing,
// don't spend here; the error advice will decide.
int status = 200;
if (response instanceof ServletServerHttpResponse) {
status = ((ServletServerHttpResponse) response).getServletResponse().getStatus();
}
if (status >= 400) {
log.debug(
"[CREDIT-DEBUG] CreditSuccessAdvice: Error status {} detected, skipping credit consumption",
status);
return body;
}
var apiKey = (String) servletReq.getAttribute(ATTR_APIKEY);
var resourceWeight = (Integer) servletReq.getAttribute(ATTR_RESOURCE_WEIGHT);
var isApiRequest = (Boolean) servletReq.getAttribute("IS_API_REQUEST");
int creditAmount = resourceWeight != null ? resourceWeight : 1;
if (apiKey != null) {
// Get current user
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
User user = null;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (Exception e) {
log.warn(
"[CREDIT-DEBUG] CreditSuccessAdvice: Could not get user for team check: {}",
e.getMessage());
}
if (user == null) {
log.error(
"[CREDIT-DEBUG] CreditSuccessAdvice: Unable to resolve user - skipping credit consumption");
return body;
}
// Check if user is in a non-personal team (must match UnifiedCreditInterceptor logic)
// IMPORTANT: Limited API users (anonymous, extra limited) always use personal credits,
// never team credits
boolean isLimitedApiUser =
auth.getAuthorities().stream()
.anyMatch(
authority ->
"ROLE_LIMITED_API_USER".equals(authority.getAuthority())
|| "ROLE_EXTRA_LIMITED_API_USER"
.equals(authority.getAuthority()));
Long targetTeamId = null;
if (!isLimitedApiUser
&& user.getTeam() != null
&& !saasTeamExtensionService.isPersonal(user.getTeam())) {
targetTeamId = user.getTeam().getId();
}
final boolean consumed;
final String creditSource;
if (targetTeamId != null) {
// User is in a non-personal team - use waterfall with leader overage
CreditConsumptionResult result =
teamCreditService.consumeCreditWithWaterfall(targetTeamId, creditAmount);
consumed = result.isSuccess();
creditSource = result.getSource();
if (!consumed) {
log.error(
"[CREDIT-DEBUG] CreditSuccessAdvice: Team credit consumption failed:"
+ " {}",
result.getMessage());
} else {
log.debug(
"[CREDIT-DEBUG] CreditSuccessAdvice: Consumed {} credits from team {}"
+ " via {}",
creditAmount,
targetTeamId,
creditSource);
}
} else {
// No team - use waterfall logic for individual credits
boolean isApiRequestFlag = Boolean.TRUE.equals(isApiRequest);
CreditConsumptionResult result =
creditService.consumeCreditWithWaterfall(
user, creditAmount, isApiRequestFlag);
consumed = result.isSuccess();
creditSource = result.getSource();
if (!consumed) {
log.error(
"[CREDIT-DEBUG] CreditSuccessAdvice: Credit consumption failed for user: {} - {}",
user.getUsername(),
result.getMessage());
}
}
if (consumed) {
servletReq.setAttribute(ATTR_CHARGED, Boolean.TRUE);
creditsConsumedCounter.increment();
// Set remaining credits header
int remainingCredits =
creditHeaderUtils.getRemainingCredits(
user, creditService, teamCreditService);
if (remainingCredits >= 0) {
response.getHeaders()
.set("X-Credits-Remaining", Integer.toString(remainingCredits));
log.warn(
"[CREDIT-HEADER] Added X-Credits-Remaining header: {}",
remainingCredits);
}
if (creditSource != null) {
response.getHeaders().set("X-Credit-Source", creditSource);
}
log.info(
"[CREDIT-DEBUG] CreditSuccessAdvice: {} credits consumed from {} for user: {}",
creditAmount,
creditSource,
user.getUsername());
}
} else {
log.warn("[CREDIT-DEBUG] CreditSuccessAdvice: No apiKey attribute found");
}
return body;
}
private String maskApiKey(String apiKey) {
if (apiKey == null || apiKey.length() < 8) {
return "***";
}
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
}
}
@@ -1,493 +0,0 @@
package stirling.software.saas.interceptor;
import org.springframework.context.annotation.Profile;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.AsyncHandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.model.TeamCredit;
import stirling.software.saas.model.UserCredit;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.ErrorTrackingService;
import stirling.software.saas.service.SaasTeamExtensionService;
import stirling.software.saas.service.SaasUserExtensionService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
// Legacy credit-billing interceptor. PAYG replaces this with PaygChargeInterceptor — disabled
// by default in saas, activate legacy-credits profile to bring it back.
@Component
@Profile("saas & legacy-credits")
@Slf4j
public class UnifiedCreditInterceptor implements AsyncHandlerInterceptor {
private final CreditService creditService;
private final ErrorTrackingService errorTrackingService;
private final CreditsProperties creditsProperties;
private final UserRepository userRepository;
private final TeamCreditService teamCreditService;
private final TeamMembershipRepository membershipRepository;
private final SaasUserExtensionService saasUserExtensionService;
private final SaasTeamExtensionService saasTeamExtensionService;
private final Counter creditsCheckedCounter;
private final Counter creditsRejectedCounter;
private final Counter jwtBypassCounter;
private final Timer creditCheckTimer;
private static final String ATTR_CREDIT_ELIGIBLE = "CREDIT_ELIGIBLE";
private static final String ATTR_API_KEY = "CREDIT_API_KEY";
private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
private static final String ATTR_CHARGED = "CREDIT_CHARGED";
public UnifiedCreditInterceptor(
CreditService creditService,
ErrorTrackingService errorTrackingService,
CreditsProperties creditsProperties,
UserRepository userRepository,
TeamCreditService teamCreditService,
TeamMembershipRepository membershipRepository,
SaasUserExtensionService saasUserExtensionService,
SaasTeamExtensionService saasTeamExtensionService,
MeterRegistry meterRegistry) {
this.creditService = creditService;
this.errorTrackingService = errorTrackingService;
this.creditsProperties = creditsProperties;
this.userRepository = userRepository;
this.teamCreditService = teamCreditService;
this.membershipRepository = membershipRepository;
this.saasUserExtensionService = saasUserExtensionService;
this.saasTeamExtensionService = saasTeamExtensionService;
this.creditsCheckedCounter =
Counter.builder("credits.validation.checked")
.description("Number of requests that had credit validation performed")
.register(meterRegistry);
this.creditsRejectedCounter =
Counter.builder("credits.validation.rejected")
.description("Number of requests rejected due to insufficient credits")
.register(meterRegistry);
this.jwtBypassCounter =
Counter.builder("credits.validation.jwt_bypass")
.description("Number of JWT requests that bypassed credit validation")
.register(meterRegistry);
this.creditCheckTimer =
Timer.builder("credits.validation.duration")
.description("Time taken to validate credits")
.register(meterRegistry);
}
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
log.debug(
"[CREDIT-DEBUG] UnifiedCreditInterceptor.preHandle() - handler: {}",
handler.getClass().getSimpleName());
// Credits system disabled - allow all requests
if (!creditsProperties.isEnabled()) {
log.debug("[CREDIT-DEBUG] Credits system disabled - allowing request");
return true;
}
// Only apply to @AutoJobPostMapping endpoints and extract resource weight
if (!(handler instanceof HandlerMethod hm)
|| !hm.getMethod().isAnnotationPresent(AutoJobPostMapping.class)) {
log.debug(
"[CREDIT-DEBUG] Handler not eligible for credit validation (no @AutoJobPostMapping)");
return true;
}
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
log.debug(
"[CREDIT-DEBUG] Authentication: {}",
auth != null ? auth.getClass().getSimpleName() : "null");
User currentUser = null;
// API key authentication always needs credit validation
if (auth instanceof ApiKeyAuthenticationToken) {
// API key users - proceed with normal credit validation
currentUser = (User) auth.getPrincipal();
} else if (auth != null && auth.isAuthenticated()) {
// JWT users - get user from authentication details
// JwtAuthenticationToken.getPrincipal() might not be a User object
// so we need to look up the user by the Supabase ID from auth.getName()
String supabaseId = AuthenticationUtils.extractSupabaseId(auth);
log.debug("[CREDIT-DEBUG] JWT authentication detected, Supabase ID: {}", supabaseId);
// Look up the User object that should exist (authentication succeeded)
try {
java.util.UUID supabaseUuid = java.util.UUID.fromString(supabaseId);
java.util.Optional<User> userOpt = userRepository.findBySupabaseId(supabaseUuid);
if (userOpt.isEmpty()) {
log.error(
"[CREDIT-DEBUG] JWT authenticated but no User found for Supabase ID: {}",
supabaseId);
response.setStatus(500);
response.setContentType("application/json");
response.getWriter()
.write(
"{\"error\":\"USER_NOT_FOUND\",\"message\":\"Authenticated user not found in database\",\"status\":500}");
return false;
}
currentUser = userOpt.get();
if (shouldApplyCreditsToJwtUser(currentUser)) {
// Anonymous users or other limited JWT users should consume credits
log.debug(
"[CREDIT-DEBUG] JWT user {} subject to credit validation due to limited role",
currentUser.getUsername());
} else {
jwtBypassCounter.increment();
log.debug(
"[CREDIT-DEBUG] JWT user {} bypassing credit validation (unlimited role)",
currentUser.getUsername());
return true;
}
} catch (IllegalArgumentException e) {
log.error("[CREDIT-DEBUG] Invalid Supabase ID format: {}", supabaseId);
response.setStatus(400);
response.setContentType("application/json");
response.getWriter()
.write(
"{\"error\":\"INVALID_USER_ID\",\"message\":\"Invalid user identifier format\",\"status\":400}");
return false;
}
} else {
// SECURITY: Block all non-authenticated requests
log.warn(
"[CREDIT-DEBUG] Non-authenticated request blocked - authentication required for credit-controlled endpoints");
response.setStatus(401); // 401 Unauthorized
response.setContentType("application/json");
response.getWriter()
.write(
"{\"error\":\"AUTHENTICATION_REQUIRED\",\"message\":\"Authentication required to access this endpoint\",\"status\":401}");
return false;
}
// Extract resource weight from annotation
AutoJobPostMapping annotation = hm.getMethod().getAnnotation(AutoJobPostMapping.class);
int resourceWeight =
Math.max(1, Math.min(100, annotation.resourceWeight())); // Clamp to 1-100
String apiKey = getApiKeyForUser(auth, currentUser);
String maskedApiKey = maskApiKey(apiKey);
log.debug(
"[CREDIT-DEBUG] Credit validation for user: {}, API key: {}, resource weight: {}",
currentUser.getUsername(),
maskedApiKey,
resourceWeight);
// Track that we're performing credit validation
creditsCheckedCounter.increment();
// Check if user has SUFFICIENT credits for this operation (with timing)
Timer.Sample sample = Timer.start();
boolean hasSufficientCredits;
int availableCredits = 0;
// Check if user is a limited API user (anonymous, extra limited)
// Limited API users always use personal credits, never team credits
boolean isLimitedApiUser =
currentUser.getAuthorities().stream()
.anyMatch(
authority ->
"ROLE_LIMITED_API_USER".equals(authority.getAuthority())
|| "ROLE_EXTRA_LIMITED_API_USER"
.equals(authority.getAuthority()));
if (auth instanceof ApiKeyAuthenticationToken) {
// API key auth - get credit balance
java.util.Optional<UserCredit> userCreditsOpt =
creditService.getUserCreditsByApiKey(apiKey);
availableCredits = userCreditsOpt.map(UserCredit::getTotalAvailableCredits).orElse(0);
hasSufficientCredits = availableCredits >= resourceWeight;
} else {
// JWT user - check team credits if user is in a non-personal team, otherwise personal
// credits
Long teamId = null;
if (!isLimitedApiUser
&& currentUser.getTeam() != null
&& !saasTeamExtensionService.isPersonal(currentUser.getTeam())) {
teamId = currentUser.getTeam().getId();
}
if (teamId != null) {
// User is in a non-personal team - check team credits + leader overage billing
java.util.Optional<TeamCredit> teamCredits =
teamCreditService.getTeamCredits(teamId);
availableCredits = teamCredits.map(TeamCredit::getTotalAvailableCredits).orElse(0);
// Check if sufficient credits OR team leader has metered billing
boolean hasTeamCredits = availableCredits >= resourceWeight;
boolean leaderHasMetered = checkTeamLeaderMeteredBilling(currentUser.getTeam());
hasSufficientCredits = hasTeamCredits || leaderHasMetered;
log.debug(
"[CREDIT-DEBUG] Checking team {} credits for user {}: available={}"
+ " required={} hasCredits={} leaderMetered={} sufficient={}",
teamId,
currentUser.getUsername(),
availableCredits,
resourceWeight,
hasTeamCredits,
leaderHasMetered,
hasSufficientCredits);
} else {
// Personal team or no team - check personal credits
UserCredit userCredits = creditService.getOrCreateUserCredits(currentUser);
availableCredits = userCredits.getTotalAvailableCredits();
hasSufficientCredits = availableCredits >= resourceWeight;
log.debug(
"[CREDIT-DEBUG] Checking personal credits for user {}: available={} required={} sufficient={}",
currentUser.getUsername(),
availableCredits,
resourceWeight,
hasSufficientCredits);
}
}
sample.stop(creditCheckTimer);
// Check if user has metered billing enabled (they can use overage credits even with
// insufficient free credits)
boolean hasMeteredBilling = saasUserExtensionService.isMeteredBillingEnabled(currentUser);
if (!hasSufficientCredits && !hasMeteredBilling) {
creditsRejectedCounter.increment();
// Enhanced message for team members
// Note: Limited API users always use personal credits, so they get personal message
String message;
if (!isLimitedApiUser
&& currentUser.getTeam() != null
&& !saasTeamExtensionService.isPersonal(currentUser.getTeam())) {
message =
"Insufficient team credits. Team leader must enable overage billing for"
+ " uninterrupted service.";
} else {
message =
"Insufficient API credits. Please purchase more credits or wait for your"
+ " monthly cycle credits to reset.";
}
log.warn(
"[CREDIT-DEBUG] Credit validation rejected - Method: {}, URI: {}, IP: {},"
+ " User-Agent: {}, User: {}, Supabase ID: {}, Reason: {}",
request.getMethod(),
request.getRequestURI(),
getClientIpAddress(request),
request.getHeader("User-Agent"),
currentUser.getUsername(),
currentUser.getSupabaseId(),
message);
response.setStatus(429); // 429 Too Many Requests
response.setContentType("application/json");
response.getWriter()
.write(
String.format(
"{\"error\":\"INSUFFICIENT_CREDITS\",\"message\":\"%s\",\"status\":429}",
message));
response.getWriter().flush();
return false;
}
// Log when metered billing users are using overage credits
if (!hasSufficientCredits && hasMeteredBilling) {
log.info(
"[CREDIT-DEBUG] Metered billing user {} proceeding with insufficient free credits (have: {}, need: {}) - will use overage credits (billed monthly)",
currentUser.getUsername(),
availableCredits,
resourceWeight);
}
// Mark request as eligible for credit consumption
request.setAttribute(ATTR_CREDIT_ELIGIBLE, Boolean.TRUE);
request.setAttribute(ATTR_API_KEY, apiKey);
request.setAttribute(ATTR_RESOURCE_WEIGHT, resourceWeight);
// Store whether this is API key or JWT authentication for advice classes
boolean isApiKeyAuth = auth instanceof ApiKeyAuthenticationToken;
request.setAttribute("IS_API_KEY_AUTH", isApiKeyAuth);
// Store IS_API_REQUEST for waterfall logic (API key requests always consume credits)
request.setAttribute("IS_API_REQUEST", isApiKeyAuth);
log.debug(
"[CREDIT-DEBUG] Credit validation passed - request marked as eligible for consumption (will consume after success/error)");
return true;
}
@Override
public void postHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView)
throws Exception {
// Success path now handled by CreditSuccessAdvice - no spending in postHandle anymore
log.debug("[CREDIT-DEBUG] postHandle: Success path will be handled by CreditSuccessAdvice");
}
@Override
public void afterCompletion(
HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
throws Exception {
// Error path now handled by CreditErrorAdvice - no spending in afterCompletion anymore
if (ex != null) {
log.debug(
"[CREDIT-DEBUG] afterCompletion: Error path will be handled by CreditErrorAdvice: {}",
ex.getClass().getSimpleName());
} else {
log.debug(
"[CREDIT-DEBUG] afterCompletion: Success path already handled by CreditSuccessAdvice");
}
}
@Override
public void afterConcurrentHandlingStarted(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
// For async requests (Callable, DeferredResult, etc.), prevent duplicate processing
// The actual postHandle/afterCompletion will be called when async processing completes
log.debug(
"[CREDIT-DEBUG] afterConcurrentHandlingStarted: Async processing started - skipping interceptor logic");
}
private String maskApiKey(String apiKey) {
if (apiKey == null || apiKey.length() < 8) {
return "***";
}
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
}
private String getClientIpAddress(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
String xRealIp = request.getHeader("X-Real-IP");
if (xRealIp != null && !xRealIp.isEmpty()) {
return xRealIp;
}
return request.getRemoteAddr();
}
/**
* Determines if credit limits should apply to a JWT user.
*
* <p>Rules:
*
* <ul>
* <li>Metered billing users: always consume (free tier first, then report overage to Stripe)
* <li>Anonymous users: consume credits (web/API)
* <li>Regular users: consume credits (web/API)
* <li>Pro users: unlimited on web UI (waterfall logic handles this), but subject to checks
* <li>API users: always consume credits
* <li>Internal API users: unlimited everywhere
* <li>Admin users: unlimited everywhere
* </ul>
*/
private boolean shouldApplyCreditsToJwtUser(User user) {
String roles = user.getRolesAsString();
// Internal API users are unlimited everywhere (for backend internal operations)
if (roles.contains("STIRLING-PDF-BACKEND-API-USER")) {
log.debug("[CREDIT-DEBUG] Internal API user {} - unlimited usage", user.getUsername());
return false;
}
// Pro users: Let them through to waterfall logic
// (Pro gets unlimited UI but API still consumes credits)
if (roles.contains("ROLE_PRO_USER")) {
log.debug(
"[CREDIT-DEBUG] Pro user {} - will be handled by waterfall logic",
user.getUsername());
return true; // Changed from false - let waterfall handle Pro exemption
}
// Admin users are unlimited everywhere
if (roles.contains("ROLE_ADMIN")) {
log.debug("[CREDIT-DEBUG] Admin user {} - unlimited usage", user.getUsername());
return false;
}
// All other users (anonymous, regular, limited API users, metered billing) consume credits
log.debug(
"[CREDIT-DEBUG] User {} with roles {} - subject to credit limits",
user.getUsername(),
roles);
return true;
}
/**
* Gets the identifier for credit consumption. For API key users, use their actual API key. For
* JWT users, use the Supabase ID as identifier (auth.getName() returns Supabase ID).
*/
private String getApiKeyForUser(Authentication auth, User user) {
if (auth instanceof ApiKeyAuthenticationToken) {
return user.getApiKey();
} else {
// For JWT users, return Supabase ID as the credit consumption identifier
return AuthenticationUtils.extractSupabaseId(auth);
}
}
/**
* Check if team leader has metered billing enabled. This allows teams to use overage billing
* when team credits are exhausted.
*
* @param team the team to check
* @return true if team leader has metered billing enabled
*/
private boolean checkTeamLeaderMeteredBilling(stirling.software.proprietary.model.Team team) {
if (team == null || team.getId() == null) {
return false;
}
try {
java.util.List<stirling.software.saas.model.TeamMembership> leaders =
membershipRepository.findByTeamIdAndRole(
team.getId(),
stirling.software.common.model.enumeration.TeamRole.LEADER);
if (leaders.isEmpty()) {
return false;
}
User leader = leaders.get(0).getUser();
return saasUserExtensionService.isMeteredBillingEnabled(leader);
} catch (Exception e) {
log.error("Error checking team leader metered billing: {}", e.getMessage());
return false;
}
}
}
@@ -1,69 +0,0 @@
package stirling.software.saas.model;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* Result of a credit consumption attempt with explicit waterfall logic. Indicates whether the
* operation succeeded and which credit source was used.
*/
@Data
@AllArgsConstructor
public class CreditConsumptionResult {
/** Whether the credit consumption succeeded */
private boolean success;
/**
* The credit source used for this operation. Possible values: "PRO_PLAN" (Pro user with
* unlimited UI access, no credits consumed); "CYCLE_CREDITS" (free monthly cycle credit
* allocation); "BOUGHT_CREDITS" (one-time purchased credits); "METERED_SUBSCRIPTION"
* (pay-what-you-use metered billing, reported to Stripe); null (operation failed; see message
* for reason).
*/
private String source;
/** Human-readable message about the result */
private String message;
/**
* Creates a successful result for unlimited access (Pro plan UI requests).
*
* @param source The credit source (typically "PRO_PLAN")
* @return CreditConsumptionResult indicating unlimited access
*/
public static CreditConsumptionResult unlimited(String source) {
return new CreditConsumptionResult(true, source, "Unlimited access");
}
/**
* Creates a successful result for credit consumption.
*
* @param source The credit source used
* @return CreditConsumptionResult indicating success
*/
public static CreditConsumptionResult success(String source) {
return new CreditConsumptionResult(true, source, "Credits consumed");
}
/**
* Creates a failure result.
*
* @param reason The reason for failure
* @return CreditConsumptionResult indicating failure
*/
public static CreditConsumptionResult failure(String reason) {
return new CreditConsumptionResult(false, null, reason);
}
/**
* Creates a failure result with custom message.
*
* @param reason The reason code
* @param message Custom human-readable message
* @return CreditConsumptionResult indicating failure
*/
public static CreditConsumptionResult failure(String reason, String message) {
return new CreditConsumptionResult(false, null, message != null ? message : reason);
}
}
@@ -1,139 +0,0 @@
package stirling.software.saas.model;
public enum ProcessingErrorType {
/**
* Validation errors. should never cost credits. Examples: missing parameters, invalid file
* types, size limits exceeded, malformed requests, authentication failures
*/
VALIDATION_ERROR,
/**
* Processing errors. should cost credits after 3rd attempt per user/endpoint. Examples: corrupt
* PDF files, unsupported PDF features, memory issues during processing, OCR failures on valid
* PDFs, conversion errors on valid files
*/
PROCESSING_ERROR,
/**
* System errors. should not cost credits (our fault). Examples: database connection issues,
* filesystem problems, service unavailable, internal server errors
*/
SYSTEM_ERROR;
/** Determine error type from exception and HTTP status */
public static ProcessingErrorType classifyError(
Throwable throwable, int httpStatus, String endpoint) {
if (throwable == null) {
return classifyByHttpStatus(httpStatus);
}
String errorMessage = throwable.getMessage();
String exceptionClass = throwable.getClass().getSimpleName();
// Validation errors (client-side issues)
if (httpStatus == 400 || httpStatus == 422) {
if (isValidationError(errorMessage, exceptionClass)) {
return VALIDATION_ERROR;
}
}
// Authentication/Authorization errors
if (httpStatus == 401 || httpStatus == 403) {
return VALIDATION_ERROR;
}
// Rate limiting
if (httpStatus == 429) {
return VALIDATION_ERROR;
}
// System errors (our fault)
if (httpStatus >= 500 || isSystemError(errorMessage, exceptionClass)) {
return SYSTEM_ERROR;
}
// Processing errors (user's data issue but valid request)
if (isProcessingError(errorMessage, exceptionClass, endpoint)) {
return PROCESSING_ERROR;
}
// Default to validation error to be safe
return VALIDATION_ERROR;
}
private static ProcessingErrorType classifyByHttpStatus(int httpStatus) {
if (httpStatus >= 400 && httpStatus < 500) {
return VALIDATION_ERROR;
} else if (httpStatus >= 500) {
return SYSTEM_ERROR;
}
return VALIDATION_ERROR;
}
private static boolean isValidationError(String errorMessage, String exceptionClass) {
if (errorMessage == null && exceptionClass == null) return false;
String[] validationKeywords = {
"validation", "invalid parameter", "missing parameter", "malformed",
"bad request", "illegal argument", "file too large", "unsupported file type",
"empty file", "no file provided", "invalid format"
};
String[] validationExceptions = {
"IllegalArgumentException",
"ValidationException",
"BindException",
"MethodArgumentNotValidException",
"MissingServletRequestParameterException",
"HttpMessageNotReadableException",
"MaxUploadSizeExceededException"
};
return containsAny(errorMessage, validationKeywords)
|| containsAny(exceptionClass, validationExceptions);
}
private static boolean isSystemError(String errorMessage, String exceptionClass) {
if (errorMessage == null && exceptionClass == null) return false;
String[] systemExceptions = {
"SQLException",
"IOException",
"OutOfMemoryError",
"TimeoutException",
"ConnectException",
"UnknownHostException",
"ServiceUnavailableException"
};
return containsAny(exceptionClass, systemExceptions);
}
private static boolean isProcessingError(
String errorMessage, String exceptionClass, String endpoint) {
if (errorMessage == null && exceptionClass == null) return false;
String[] processingExceptions = {
"PDFException", "COSVisitorException", "InvalidPDFException",
"ConversionException", "OCRException", "ParseException"
};
// If we're checking errors for an endpoint, it's already been identified as a tracked
// endpoint
// through @AutoJobPostMapping annotation, so we can assume it's a PDF processing endpoint
return containsAny(exceptionClass, processingExceptions)
|| (endpoint != null && !isValidationError(errorMessage, exceptionClass));
}
private static boolean containsAny(String text, String[] keywords) {
if (text == null) return false;
String lowerText = text.toLowerCase();
for (String keyword : keywords) {
if (lowerText.contains(keyword.toLowerCase())) {
return true;
}
}
return false;
}
}
@@ -1,131 +0,0 @@
package stirling.software.saas.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.model.Team;
/** Shared credit pool for multi-member teams; see {@link UserCredit} for the per-user variant. */
@Entity
@Table(name = "team_credits")
@NoArgsConstructor
@Getter
@Setter
public class TeamCredit implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "credit_id")
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id", nullable = false, unique = true)
@OnDelete(action = OnDeleteAction.CASCADE)
private Team team;
@Column(name = "cycle_credits_remaining")
private Integer cycleCreditsRemaining = 0;
@Column(name = "cycle_credits_allocated")
private Integer cycleCreditsAllocated = 0;
@Column(name = "bought_credits_remaining")
private Integer boughtCreditsRemaining = 0;
@Column(name = "total_bought_credits")
private Integer totalBoughtCredits = 0;
@Column(name = "last_cycle_reset_at")
private LocalDateTime lastCycleResetAt;
@Column(name = "last_api_usage")
private LocalDateTime lastApiUsage;
@Column(name = "total_api_calls_made")
private Long totalApiCallsMade = 0L;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Version
@Column(name = "version")
private Long version;
public TeamCredit(Team team) {
this.team = team;
}
public int getTotalAvailableCredits() {
return (cycleCreditsRemaining != null ? cycleCreditsRemaining : 0)
+ (boughtCreditsRemaining != null ? boughtCreditsRemaining : 0);
}
public boolean hasCreditsAvailable() {
return getTotalAvailableCredits() > 0;
}
/**
* Consume a credit from the team pool. Consumes cycle credits first, then bought credits.
*
* @return true if a credit was consumed, false if no credits available
*/
public boolean consumeCredit() {
if (cycleCreditsRemaining != null && cycleCreditsRemaining > 0) {
cycleCreditsRemaining--;
totalApiCallsMade++;
lastApiUsage = LocalDateTime.now();
return true;
} else if (boughtCreditsRemaining != null && boughtCreditsRemaining > 0) {
boughtCreditsRemaining--;
totalApiCallsMade++;
lastApiUsage = LocalDateTime.now();
return true;
}
return false;
}
public void addBoughtCredits(int credits) {
if (credits > 0) {
boughtCreditsRemaining =
(boughtCreditsRemaining != null ? boughtCreditsRemaining : 0) + credits;
totalBoughtCredits = (totalBoughtCredits != null ? totalBoughtCredits : 0) + credits;
}
}
public void resetCycleCredits(int cycleAllocation, LocalDateTime resetTime) {
this.cycleCreditsAllocated = cycleAllocation;
this.cycleCreditsRemaining = cycleAllocation;
this.lastCycleResetAt = resetTime;
}
public boolean isCycleResetDue(LocalDateTime lastScheduledReset) {
return lastCycleResetAt == null || lastCycleResetAt.isBefore(lastScheduledReset);
}
}
@@ -1,133 +0,0 @@
package stirling.software.saas.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
/**
* Per-user credit pool. Layers a renewable monthly cycle pool ({@code cycleCreditsRemaining}) over
* a non-expiring purchased pool ({@code boughtCreditsRemaining}); cycle credits consume first.
*/
@Entity
@Table(name = "user_credits")
@NoArgsConstructor
@Getter
@Setter
public class UserCredit implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "credit_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;
@Column(name = "cycle_credits_remaining")
private Integer cycleCreditsRemaining = 0;
@Column(name = "cycle_credits_allocated")
private Integer cycleCreditsAllocated = 0;
@Column(name = "bought_credits_remaining")
private Integer boughtCreditsRemaining = 0;
@Column(name = "total_bought_credits")
private Integer totalBoughtCredits = 0;
@Column(name = "last_cycle_reset_at")
private LocalDateTime lastCycleResetAt;
@Column(name = "last_api_usage")
private LocalDateTime lastApiUsage;
@Column(name = "total_api_calls_made")
private Long totalApiCallsMade = 0L;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Version
@Column(name = "version")
private Long version;
public UserCredit(User user) {
this.user = user;
// Cycle credits are initialized by CreditService after this object is created,
// typically during user registration or at the start of a new billing cycle,
// using values from the application configuration.
}
public int getTotalAvailableCredits() {
return (cycleCreditsRemaining != null ? cycleCreditsRemaining : 0)
+ (boughtCreditsRemaining != null ? boughtCreditsRemaining : 0);
}
public boolean hasCreditsAvailable() {
return getTotalAvailableCredits() > 0;
}
public boolean consumeCredit() {
// Consume cycle credits first, then bought credits.
if (cycleCreditsRemaining != null && cycleCreditsRemaining > 0) {
cycleCreditsRemaining--;
totalApiCallsMade++;
lastApiUsage = LocalDateTime.now();
return true;
} else if (boughtCreditsRemaining != null && boughtCreditsRemaining > 0) {
boughtCreditsRemaining--;
totalApiCallsMade++;
lastApiUsage = LocalDateTime.now();
return true;
}
return false;
}
public void addBoughtCredits(int credits) {
if (credits > 0) {
boughtCreditsRemaining =
(boughtCreditsRemaining != null ? boughtCreditsRemaining : 0) + credits;
totalBoughtCredits = (totalBoughtCredits != null ? totalBoughtCredits : 0) + credits;
}
}
public void resetCycleCredits(int cycleAllocation, LocalDateTime resetTime) {
this.cycleCreditsAllocated = cycleAllocation;
this.cycleCreditsRemaining = cycleAllocation;
this.lastCycleResetAt = resetTime;
}
public boolean isCycleResetDue(LocalDateTime lastScheduledReset) {
return lastCycleResetAt == null || lastCycleResetAt.isBefore(lastScheduledReset);
}
}
@@ -1,95 +0,0 @@
package stirling.software.saas.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
@Entity
@Table(name = "user_error_tracker")
@NoArgsConstructor
@Getter
@Setter
public class UserErrorTracker implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "error_tracker_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "endpoint")
private String endpoint;
@Column(name = "processing_error_count")
private Integer processingErrorCount = 0;
@Column(name = "last_processing_error")
private LocalDateTime lastProcessingError;
@Column(name = "reset_after")
private LocalDateTime resetAfter;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public UserErrorTracker(User user, String endpoint, int ttlMinutes) {
this.user = user;
this.endpoint = endpoint;
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
}
public boolean shouldChargeForProcessingError(int freeProcessingErrors) {
return processingErrorCount != null && processingErrorCount > freeProcessingErrors;
}
public void recordProcessingError(int ttlMinutes) {
this.processingErrorCount = (processingErrorCount != null ? processingErrorCount : 0) + 1;
this.lastProcessingError = LocalDateTime.now();
// Refresh TTL on each error
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
}
public void resetErrorCount(int ttlMinutes) {
this.processingErrorCount = 0;
this.lastProcessingError = null;
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
}
public boolean isExpired() {
return resetAfter != null && LocalDateTime.now().isAfter(resetAfter);
}
public int getErrorsUntilCharged(int freeProcessingErrors) {
int current = processingErrorCount != null ? processingErrorCount : 0;
return Math.max(0, freeProcessingErrors + 1 - current);
}
}
@@ -51,10 +51,9 @@ import stirling.software.saas.payg.wallet.WalletLedgerEntry;
* The real-charging path lives in a separate follow-up and reuses the same orchestration — only the
* side-effect (shadow row vs ledger entry + Stripe call) differs.
*
* <p>The {@code legacyCreditsCharged} field on the shadow row is set to {@code 0} here. When the
* legacy {@code CreditService} is wired to call this service (separate PR), the legacy debit amount
* becomes available and {@code diffPct} can be computed against it; until then the shadow row
* captures the PAYG units only.
* <p>The {@code legacyCreditsCharged} field on the shadow row is set to {@code 0}: the legacy
* credit engine has been removed, so there is no legacy debit to compare against and {@code
* diffPct} stays {@code 0}. The shadow row captures the PAYG units only.
*/
@Service
@Profile("saas")
@@ -281,8 +280,7 @@ public class JobChargeService {
// Free-vs-paid split fixed at charge time: paid (metered) = paygUnits - freeUnitsConsumed,
// and a refund restores freeUnitsConsumed to the team's grant.
row.setFreeUnitsConsumed(freeUnitsConsumed);
// No legacy comparison yet — wired when the shadow path is connected to the legacy
// CreditService in the follow-up PR. Until then, diff stays at 0.
// No legacy comparison: the legacy credit engine has been removed, so diff stays at 0.
row.setLegacyCreditsCharged(0);
row.setDiffPct(0);
row.setStatus(ShadowChargeStatus.CHARGED);
@@ -53,9 +53,7 @@ import stirling.software.saas.payg.model.ProcessType;
import stirling.software.saas.util.AuthenticationUtils;
/**
* The hot-path PAYG interceptor. Mirrors the {@code UnifiedCreditInterceptor} shape: registered
* after it in {@code PaygWebMvcConfig} so legacy credit-rejection short-circuits before we waste
* work hashing inputs.
* The hot-path PAYG interceptor, registered in {@code PaygWebMvcConfig}.
*
* <p>{@code preHandle}: gates on {@code @AutoJobPostMapping} OR {@code @RequiresFeature} (the
* latter lets AI controllers — JSON-bodied, no AutoJobPostMapping — bill correctly), reads the
@@ -18,10 +18,8 @@ import stirling.software.saas.payg.entitlement.EntitlementGuard;
* <li>{@link PaygResponseBodyWrapperFilter} as a Servlet filter — registered with no explicit
* order so it sits at the end of the Spring filter chain (after all security filters). Pure
* response-wrapping plumbing.
* <li>{@link PaygChargeInterceptor} as a Spring MVC interceptor — registered AFTER {@code
* UnifiedCreditInterceptor} so legacy credit rejections short-circuit before we hash inputs.
* Both intercept {@code /api/**} with the same admin/info/health exclusions as the legacy
* config.
* <li>{@link PaygChargeInterceptor} as a Spring MVC interceptor — intercepts {@code /api/**} with
* admin/info/health exclusions.
* </ul>
*/
@Configuration
@@ -42,10 +40,9 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
}
/**
* The {@code PaygChargeInterceptor} runs after the {@link #ENTITLEMENT_GUARD_ORDER guard} (and
* after the legacy {@code UnifiedCreditInterceptor}, default order 0), so {@code openProcess}
* only fires for requests the guard has admitted. See {@link #ENTITLEMENT_GUARD_ORDER} for the
* full ordering rationale.
* The {@code PaygChargeInterceptor} runs after the {@link #ENTITLEMENT_GUARD_ORDER guard}, so
* {@code openProcess} only fires for requests the guard has admitted. See {@link
* #ENTITLEMENT_GUARD_ORDER} for the full ordering rationale.
*/
public static final int INTERCEPTOR_ORDER = 1000;
@@ -57,9 +54,7 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
* short-circuits with its 402 before the charge interceptor ever runs. A blocked request
* therefore never opens a process, materialises inputs, or writes a charge: a refused operation
* must not bill, and running the guard first guarantees that structurally rather than by
* compensating after the fact. Stays above the legacy {@code UnifiedCreditInterceptor} (default
* order 0, only registered under the {@code legacy-credits} profile) so a legacy rejection
* still wins.
* compensating after the fact.
*/
public static final int ENTITLEMENT_GUARD_ORDER = 900;
@@ -67,20 +62,12 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(paygChargeInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns(
"/api/v1/credits/**",
"/api/v1/config/**",
"/api/v1/info/**",
"/api/v1/admin/**")
.excludePathPatterns("/api/v1/config/**", "/api/v1/info/**", "/api/v1/admin/**")
.order(INTERCEPTOR_ORDER);
registry.addInterceptor(entitlementGuard)
.addPathPatterns("/api/**")
.excludePathPatterns(
"/api/v1/credits/**",
"/api/v1/config/**",
"/api/v1/info/**",
"/api/v1/admin/**")
.excludePathPatterns("/api/v1/config/**", "/api/v1/info/**", "/api/v1/admin/**")
.order(ENTITLEMENT_GUARD_ORDER);
}
}
@@ -1,68 +0,0 @@
package stirling.software.saas.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.saas.model.TeamCredit;
@Repository
public interface TeamCreditRepository extends JpaRepository<TeamCredit, Long> {
/** Find team credits by team ID. */
@Query("SELECT tc FROM TeamCredit tc WHERE tc.team.id = :teamId")
Optional<TeamCredit> findByTeamId(@Param("teamId") Long teamId);
/**
* Atomically consume credits from the team pool. Uses the {@code @Version} column on {@link
* TeamCredit} for optimistic locking - concurrent attempts will fail-fast rather than
* over-deduct. Returns 1 on success, 0 if insufficient balance or version conflict.
*/
@Modifying
@Query(
value =
"""
UPDATE team_credits
SET cycle_credits_remaining = CASE
WHEN cycle_credits_remaining >= :amount THEN cycle_credits_remaining - :amount
WHEN cycle_credits_remaining > 0 AND bought_credits_remaining >= (:amount - cycle_credits_remaining)
THEN 0
ELSE cycle_credits_remaining
END,
bought_credits_remaining = CASE
WHEN cycle_credits_remaining >= :amount THEN bought_credits_remaining
WHEN cycle_credits_remaining > 0 AND bought_credits_remaining >= (:amount - cycle_credits_remaining)
THEN bought_credits_remaining - (:amount - cycle_credits_remaining)
WHEN cycle_credits_remaining = 0 AND bought_credits_remaining >= :amount
THEN bought_credits_remaining - :amount
ELSE bought_credits_remaining
END,
total_api_calls_made = total_api_calls_made + :amount,
last_api_usage = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP,
version = version + 1
WHERE team_id = :teamId
AND (cycle_credits_remaining + bought_credits_remaining) >= :amount
""",
nativeQuery = true)
int consumeCredit(@Param("teamId") Long teamId, @Param("amount") int amount);
@Query(
"SELECT CASE WHEN COUNT(tc) > 0 THEN true ELSE false END FROM TeamCredit tc WHERE tc.team.id = :teamId")
boolean existsByTeamId(@Param("teamId") Long teamId);
@Modifying
@Query("DELETE FROM TeamCredit tc WHERE tc.team.id = :teamId")
void deleteByTeamId(@Param("teamId") Long teamId);
@Query(
"SELECT tc FROM TeamCredit tc WHERE tc.lastCycleResetAt IS NULL OR tc.lastCycleResetAt < :lastScheduledReset")
List<TeamCredit> findCreditsNeedingCycleReset(
@Param("lastScheduledReset") LocalDateTime lastScheduledReset);
}
@@ -1,148 +0,0 @@
package stirling.software.saas.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.UserCredit;
/**
* JPA repository for {@link UserCredit}. Includes JPQL queries for the common read paths and native
* SQL for atomic credit-consumption updates (avoids select-then-update races).
*
* <p>Native queries reference {@code user_credits} and {@code users} unqualified — they pick up
* Hibernate's {@code default_schema} (set to {@code stirling_pdf} in {@code
* application-saas.properties}). Keeping the schema out of the SQL means a future schema rename is
* a one-property change instead of a sweep of native SQL.
*/
@Repository
public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
Optional<UserCredit> findByUser(User user);
Optional<UserCredit> findByUserId(Long userId);
@Query(
"SELECT uc FROM UserCredit uc WHERE uc.lastCycleResetAt IS NULL OR uc.lastCycleResetAt < :lastScheduledReset")
List<UserCredit> findCreditsNeedingCycleReset(
@Param("lastScheduledReset") LocalDateTime lastScheduledReset);
@Query("SELECT SUM(uc.totalApiCallsMade) FROM UserCredit uc")
Long getTotalApiCallsAcrossAllUsers();
@Query("SELECT SUM(uc.cycleCreditsRemaining + uc.boughtCreditsRemaining) FROM UserCredit uc")
Long getTotalAvailableCreditsAcrossAllUsers();
@Query("SELECT uc FROM UserCredit uc WHERE uc.user.apiKey = :apiKey")
Optional<UserCredit> findByUserApiKey(@Param("apiKey") String apiKey);
@Query("SELECT uc FROM UserCredit uc WHERE uc.user.supabaseId = :supabaseId")
Optional<UserCredit> findBySupabaseId(@Param("supabaseId") UUID supabaseId);
@Query("SELECT COUNT(uc) FROM UserCredit uc WHERE uc.lastApiUsage >= :since")
Long countActiveUsersInPeriod(@Param("since") LocalDateTime since);
@Modifying
@Query(
value =
"UPDATE user_credits "
+ "SET "
+ " cycle_credits_remaining = "
+ " CASE "
+ " WHEN cycle_credits_remaining >= :creditAmount THEN cycle_credits_remaining - :creditAmount "
+ " ELSE 0 "
+ " END, "
+ " bought_credits_remaining = "
+ " CASE "
+ " WHEN cycle_credits_remaining < :creditAmount "
+ " THEN GREATEST(0, bought_credits_remaining - (:creditAmount - cycle_credits_remaining)) "
+ " ELSE bought_credits_remaining "
+ " END, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT user_id FROM users WHERE api_key = :apiKey) "
+ " AND (cycle_credits_remaining + bought_credits_remaining >= :creditAmount)",
nativeQuery = true)
int consumeCredit(@Param("apiKey") String apiKey, @Param("creditAmount") int creditAmount);
@Modifying
@Query(
value =
"UPDATE user_credits "
+ "SET "
+ " cycle_credits_remaining = "
+ " CASE "
+ " WHEN cycle_credits_remaining >= :creditAmount THEN cycle_credits_remaining - :creditAmount "
+ " ELSE 0 "
+ " END, "
+ " bought_credits_remaining = "
+ " CASE "
+ " WHEN cycle_credits_remaining < :creditAmount "
+ " THEN GREATEST(0, bought_credits_remaining - (:creditAmount - cycle_credits_remaining)) "
+ " ELSE bought_credits_remaining "
+ " END, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
+ " AND (cycle_credits_remaining + bought_credits_remaining >= :creditAmount)",
nativeQuery = true)
int consumeCreditBySupabaseId(
@Param("supabaseId") UUID supabaseId, @Param("creditAmount") int creditAmount);
/**
* Consumes ONLY cycle credits (does not touch bought credits). Used in explicit waterfall
* logic.
*/
@Modifying
@Query(
value =
"UPDATE user_credits "
+ "SET "
+ " cycle_credits_remaining = cycle_credits_remaining - :amount, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
+ " AND cycle_credits_remaining >= :amount",
nativeQuery = true)
int consumeCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
/** Consumes ONLY bought credits (does not touch cycle credits). */
@Modifying
@Query(
value =
"UPDATE user_credits "
+ "SET "
+ " bought_credits_remaining = bought_credits_remaining - :amount, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
+ " AND bought_credits_remaining >= :amount",
nativeQuery = true)
int consumeBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
/** Checks if user has sufficient cycle credits (does NOT consume them). */
@Query(
value =
"SELECT CASE WHEN uc.cycle_credits_remaining >= :amount THEN TRUE ELSE FALSE END "
+ "FROM user_credits uc "
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId)",
nativeQuery = true)
Boolean hasCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
/** Checks if user has sufficient bought credits (does NOT consume them). */
@Query(
value =
"SELECT CASE WHEN uc.bought_credits_remaining >= :amount THEN TRUE ELSE FALSE END "
+ "FROM user_credits uc "
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId)",
nativeQuery = true)
Boolean hasBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
}
@@ -1,41 +0,0 @@
package stirling.software.saas.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.UserErrorTracker;
public interface UserErrorTrackerRepository extends JpaRepository<UserErrorTracker, Long> {
Optional<UserErrorTracker> findByUserAndEndpoint(User user, String endpoint);
Optional<UserErrorTracker> findByUserIdAndEndpoint(Long userId, String endpoint);
@Query(
"SELECT uet FROM UserErrorTracker uet WHERE uet.user.apiKey = :apiKey AND uet.endpoint = :endpoint")
Optional<UserErrorTracker> findByUserApiKeyAndEndpoint(
@Param("apiKey") String apiKey, @Param("endpoint") String endpoint);
@Query("SELECT uet FROM UserErrorTracker uet WHERE uet.resetAfter <= :currentDateTime")
List<UserErrorTracker> findExpiredErrorTrackers(
@Param("currentDateTime") LocalDateTime currentDateTime);
@Modifying
@Query("DELETE FROM UserErrorTracker uet WHERE uet.resetAfter <= :currentDateTime")
int deleteExpiredErrorTrackers(@Param("currentDateTime") LocalDateTime currentDateTime);
@Query(
"SELECT uet FROM UserErrorTracker uet WHERE uet.user = :user AND uet.processingErrorCount >= 3")
List<UserErrorTracker> findHighErrorCountForUser(@Param("user") User user);
@Query(
"SELECT COUNT(uet) FROM UserErrorTracker uet WHERE uet.processingErrorCount >= :threshold")
Long countUsersWithHighErrorCount(@Param("threshold") int threshold);
}
@@ -63,7 +63,6 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
private final TeamService teamService;
private final UserService userService;
private final SupabaseUserService supabaseUserService;
private final stirling.software.saas.service.CreditService creditService;
private final SaasTeamService saasTeamService;
private final JwtDecoder jwtDecoder;
private final AuthenticationEntryPoint authenticationEntryPoint =
@@ -73,13 +72,11 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
TeamService teamService,
UserService userService,
SupabaseUserService supabaseUserService,
stirling.software.saas.service.CreditService creditService,
SaasTeamService saasTeamService,
JwtDecoder jwtDecoder) {
this.teamService = teamService;
this.userService = userService;
this.supabaseUserService = supabaseUserService;
this.creditService = creditService;
this.saasTeamService = saasTeamService;
this.jwtDecoder = jwtDecoder;
}
@@ -265,7 +262,10 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
user.setUsername(supabaseUser.getEmail());
}
try {
return userService.saveUser(user);
User saved = userService.saveUser(user);
// Give the account its own team rather than the shared Default team.
saved.setTeam(saasTeamService.ensurePersonalTeam(saved));
return saved;
} catch (DataIntegrityViolationException e) {
log.warn(
"Email collision upgrading anonymous user {} to {}: {}",
@@ -347,7 +347,8 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
newUser.setEnabled(true);
newUser.setFirstLogin(true);
newUser.setRoleName(roleId);
newUser.setTeam(teamService.getOrCreateDefaultTeam());
// No shared Default team; a per-user personal team is assigned after save (team_id
// nullable).
newUser.setAuthenticationType(authenticationType);
newUser.setSupabaseId(supabaseId);
newUser.addAuthority(new Authority(roleId, newUser));
@@ -382,18 +383,7 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
// Only the DB-race winner runs first-time init; the losers skip it.
if (weCreatedThisUser) {
try {
creditService.getOrCreateUserCredits(savedUser);
} catch (Exception e) {
log.warn(
"Failed to initialize credits for new user {} ({}): {}",
LogRedactionUtils.redactSupabaseId(supabaseId),
LogRedactionUtils.redactEmail(savedUser.getUsername()),
e.getMessage());
}
try {
saasTeamService.createPersonalTeam(savedUser);
savedUser = userService.findBySupabaseId(supabaseId).orElse(savedUser);
savedUser.setTeam(saasTeamService.ensurePersonalTeam(savedUser));
} catch (Exception e) {
log.warn(
"Failed to create personal team for new user {} ({}): {}",
@@ -49,7 +49,6 @@ import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.SaasTeamService;
import stirling.software.saas.service.SupabaseUserService;
@@ -66,7 +65,6 @@ public class SupabaseSecurityConfig {
private final UserService userService;
private final TeamService teamService;
private final SupabaseUserService supabaseUserService;
private final CreditService creditService;
private final SaasTeamService saasTeamService;
private final ApplicationProperties applicationProperties;
@@ -121,7 +119,6 @@ public class SupabaseSecurityConfig {
teamService,
userService,
supabaseUserService,
creditService,
saasTeamService,
jwtDecoder),
BearerTokenAuthenticationFilter.class)
@@ -300,7 +297,7 @@ public class SupabaseSecurityConfig {
"Accept",
"Origin",
"X-API-KEY"));
cfg.setExposedHeaders(List.of("WWW-Authenticate", "X-Credits-Remaining"));
cfg.setExposedHeaders(List.of("WWW-Authenticate"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
@@ -1,67 +0,0 @@
package stirling.software.saas.service;
import java.time.LocalDateTime;
import java.time.ZoneId;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.config.CreditsProperties;
@Service
@Profile("saas")
@Slf4j
@RequiredArgsConstructor
public class CreditResetScheduler {
private final CreditService creditService;
private final CreditsProperties creditsProperties;
/**
* Reset cycle credits for all users and teams on the 1st of each month at 2 AM UTC This runs
* monthly, resetting credits based on user roles and team seats
*/
@Scheduled(cron = "${credits.reset.cron:0 0 2 1 * *}", zone = "${credits.reset.zone:UTC}")
public void resetCycleCredits() {
log.info(
"Starting monthly credit reset for all users and teams (schedule: {}, zone: {})",
creditsProperties.getReset().getCron(),
creditsProperties.getReset().getZone());
try {
ZoneId configuredZone = ZoneId.of(creditsProperties.getReset().getZone());
LocalDateTime resetTime = LocalDateTime.now(configuredZone);
creditService.resetCycleCreditsForAllUsers(resetTime);
creditService.resetCycleCreditsForAllTeams(resetTime);
log.info("Monthly credit reset completed successfully at {}", resetTime);
} catch (Exception e) {
log.error("Error during monthly credit reset", e);
}
}
// NOTE: The startup catch-up reset (formerly @EventListener(ApplicationReadyEvent)) was
// removed. It bulk-looped every user on each boot (per-row save), hammering the DB and
// stalling boot on large user tables. Per-user cycle resets already happen lazily in
// CreditService.getOrCreateUserCredits (isCycleResetDue), and the monthly cron above still
// performs the scheduled reset.
/**
* Cleanup and maintenance task; runs daily at 3 AM UTC. Performs maintenance tasks like
* cleaning up old data.
*/
@Scheduled(cron = "0 0 3 * * *", zone = "UTC")
public void performDailyMaintenance() {
log.debug("Starting daily credit system maintenance");
try {
// API call history cleanup is no longer needed; audit system handles this
log.debug("Daily credit system maintenance completed");
} catch (Exception e) {
log.error("Error during daily credit system maintenance", e);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,315 +0,0 @@
package stirling.software.saas.service;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.model.ProcessingErrorType;
import stirling.software.saas.model.UserErrorTracker;
import stirling.software.saas.repository.UserErrorTrackerRepository;
@Service
@Profile("saas")
@Slf4j
@Transactional
public class ErrorTrackingService {
private final UserErrorTrackerRepository errorTrackerRepository;
private final UserRepository userRepository;
private final CreditsProperties creditsProperties;
/**
* Local cache for error counts to reduce database chatter.
*
* <p>This cache is used to temporarily store error counts for each API key and endpoint,
* reducing the frequency of database writes and lookups.
*
* <p><b>Nullability:</b> This field may be {@code null} if local caching is disabled via {@link
* CreditsProperties#getCache()#isLocalEnabled()}. All usages must check for null before
* accessing or invoking methods on this cache.
*
* <p><b>Lifecycle:</b> The cache is initialized in the constructor based on configuration and
* remains unchanged for the lifetime of this service instance.
*
* <p><b>Thread-safety:</b> The underlying Caffeine cache is thread-safe.
*/
private final Cache<String, ErrorCountCache> errorCountCache;
public ErrorTrackingService(
UserErrorTrackerRepository errorTrackerRepository,
UserRepository userRepository,
CreditsProperties creditsProperties) {
this.errorTrackerRepository = errorTrackerRepository;
this.userRepository = userRepository;
this.creditsProperties = creditsProperties;
// Initialize cache based on configuration
this.errorCountCache =
creditsProperties.getCache().isLocalEnabled()
? Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(
creditsProperties.getErrors().getTtlMinutes(),
TimeUnit.MINUTES)
.build()
: null;
}
/**
* Record an error and determine if credits should be consumed
*
* @param apiKey User's API key
* @param endpoint The endpoint that failed
* @param throwable The exception that occurred
* @param httpStatus HTTP response status
* @return true if credits should be consumed for this error
*/
public boolean recordErrorAndShouldConsumeCredit(
String apiKey, String endpoint, Throwable throwable, int httpStatus) {
ProcessingErrorType errorType =
ProcessingErrorType.classifyError(throwable, httpStatus, endpoint);
// Never charge for validation errors or system errors
if (errorType != ProcessingErrorType.PROCESSING_ERROR) {
log.debug(
"Error classified as {}, no credit consumption for API key: {}, endpoint: {}",
errorType,
maskApiKey(apiKey),
endpoint);
return false;
}
String cacheKey = apiKey + "|" + endpoint;
if (errorCountCache != null) {
// Use cache for fast tracking
ErrorCountCache cachedCount = errorCountCache.get(cacheKey, k -> new ErrorCountCache());
cachedCount.incrementErrorCount();
boolean shouldCharge =
cachedCount.getErrorCount()
> creditsProperties.getErrors().getFreeProcessingErrors();
// Persist to DB when crossing the charging threshold or on first error
if (shouldCharge
&& cachedCount.getErrorCount()
== creditsProperties.getErrors().getFreeProcessingErrors() + 1) {
persistErrorToDatabase(apiKey, endpoint);
}
log.info(
"Processing error recorded (cached) for API key: {}, endpoint: {}, error count: {}, will charge: {}",
maskApiKey(apiKey),
endpoint,
cachedCount.getErrorCount(),
shouldCharge);
return shouldCharge;
} else {
// Fallback to direct DB tracking
return recordErrorDirectToDatabase(apiKey, endpoint);
}
}
private boolean recordErrorDirectToDatabase(String apiKey, String endpoint) {
Optional<User> userOpt = userRepository.findByApiKey(apiKey);
if (userOpt.isEmpty()) {
log.warn("User not found for API key: {}", maskApiKey(apiKey));
return false;
}
User user = userOpt.get();
UserErrorTracker tracker = getOrCreateErrorTracker(user, endpoint);
tracker.recordProcessingError(creditsProperties.getErrors().getTtlMinutes());
errorTrackerRepository.save(tracker);
boolean shouldCharge =
tracker.shouldChargeForProcessingError(
creditsProperties.getErrors().getFreeProcessingErrors());
log.info(
"Processing error recorded (DB) for user: {}, endpoint: {}, error count: {}, will charge: {}",
user.getUsername(),
endpoint,
tracker.getProcessingErrorCount(),
shouldCharge);
return shouldCharge;
}
private void persistErrorToDatabase(String apiKey, String endpoint) {
try {
Optional<User> userOpt = userRepository.findByApiKey(apiKey);
if (userOpt.isPresent()) {
User user = userOpt.get();
UserErrorTracker tracker = getOrCreateErrorTracker(user, endpoint);
// Set to threshold + 1 to indicate charging has started
tracker.setProcessingErrorCount(
creditsProperties.getErrors().getFreeProcessingErrors() + 1);
tracker.setLastProcessingError(LocalDateTime.now());
tracker.setResetAfter(
LocalDateTime.now()
.plusMinutes(creditsProperties.getErrors().getTtlMinutes()));
errorTrackerRepository.save(tracker);
log.debug(
"Persisted error threshold crossing to DB for API key: {}, endpoint: {}",
maskApiKey(apiKey),
endpoint);
}
} catch (Exception e) {
log.error(
"Failed to persist error to database for API key: {}, endpoint: {}",
maskApiKey(apiKey),
endpoint,
e);
}
}
/** Check if a user has high error counts that might indicate abuse */
public boolean hasHighErrorCount(String apiKey, String endpoint) {
Optional<UserErrorTracker> trackerOpt =
errorTrackerRepository.findByUserApiKeyAndEndpoint(apiKey, endpoint);
return trackerOpt
.map(
t ->
t.shouldChargeForProcessingError(
creditsProperties.getErrors().getFreeProcessingErrors()))
.orElse(false);
}
/** Get error information for a user and endpoint */
public ErrorInfo getErrorInfo(String apiKey, String endpoint) {
String cacheKey = apiKey + "|" + endpoint;
if (errorCountCache != null) {
// Check cache first
ErrorCountCache cachedCount = errorCountCache.getIfPresent(cacheKey);
if (cachedCount != null) {
int currentCount = cachedCount.getErrorCount();
int freeErrors = creditsProperties.getErrors().getFreeProcessingErrors();
return new ErrorInfo(
currentCount,
Math.max(0, freeErrors - currentCount),
currentCount > freeErrors,
cachedCount.getLastErrorTime());
}
}
// Fallback to DB
Optional<UserErrorTracker> trackerOpt =
errorTrackerRepository.findByUserApiKeyAndEndpoint(apiKey, endpoint);
if (trackerOpt.isEmpty()) {
return new ErrorInfo(
0, creditsProperties.getErrors().getFreeProcessingErrors(), false, null);
}
UserErrorTracker tracker = trackerOpt.get();
// Reset if expired
if (tracker.isExpired()) {
tracker.resetErrorCount(creditsProperties.getErrors().getTtlMinutes());
errorTrackerRepository.save(tracker);
return new ErrorInfo(
0, creditsProperties.getErrors().getFreeProcessingErrors(), false, null);
}
return new ErrorInfo(
tracker.getProcessingErrorCount(),
tracker.getErrorsUntilCharged(
creditsProperties.getErrors().getFreeProcessingErrors()),
tracker.shouldChargeForProcessingError(
creditsProperties.getErrors().getFreeProcessingErrors()),
tracker.getLastProcessingError());
}
private UserErrorTracker getOrCreateErrorTracker(User user, String endpoint) {
Optional<UserErrorTracker> existing =
errorTrackerRepository.findByUserAndEndpoint(user, endpoint);
if (existing.isPresent()) {
UserErrorTracker tracker = existing.get();
// Reset if expired
if (tracker.isExpired()) {
tracker.resetErrorCount(creditsProperties.getErrors().getTtlMinutes());
}
return tracker;
}
// Create new tracker
return new UserErrorTracker(user, endpoint, creditsProperties.getErrors().getTtlMinutes());
}
/** Clean up expired error trackers every hour */
@Scheduled(cron = "0 0 * * * *")
public void cleanupExpiredErrorTrackers() {
try {
int deleted = errorTrackerRepository.deleteExpiredErrorTrackers(LocalDateTime.now());
if (deleted > 0) {
log.debug("Cleaned up {} expired error trackers", deleted);
}
} catch (Exception e) {
log.error("Error cleaning up expired error trackers", e);
}
}
private String maskApiKey(String apiKey) {
if (apiKey == null || apiKey.length() < 8) {
return "***";
}
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
}
/** Information about user's error status for an endpoint */
public static class ErrorInfo {
public final int currentErrorCount;
public final int errorsUntilCharged;
public final boolean isChargingForErrors;
public final LocalDateTime lastError;
public ErrorInfo(
int currentErrorCount,
int errorsUntilCharged,
boolean isChargingForErrors,
LocalDateTime lastError) {
this.currentErrorCount = currentErrorCount;
this.errorsUntilCharged = errorsUntilCharged;
this.isChargingForErrors = isChargingForErrors;
this.lastError = lastError;
}
}
/** Cache entry for tracking error counts in memory */
private static class ErrorCountCache {
private int errorCount = 0;
private LocalDateTime lastErrorTime = LocalDateTime.now();
public void incrementErrorCount() {
errorCount++;
lastErrorTime = LocalDateTime.now();
}
public int getErrorCount() {
return errorCount;
}
public LocalDateTime getLastErrorTime() {
return lastErrorTime;
}
}
}
@@ -2,7 +2,6 @@ package stirling.software.saas.service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
@@ -21,16 +20,12 @@ import stirling.software.proprietary.security.database.repository.UserRepository
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.saas.billing.repository.BillingSubscriptionRepository;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.config.SupabaseConfigurationProperties;
import stirling.software.saas.model.TeamCredit;
import stirling.software.saas.model.TeamInvitation;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.repository.SaasTeamExtensionsRepository;
import stirling.software.saas.repository.TeamCreditRepository;
import stirling.software.saas.repository.TeamInvitationRepository;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.repository.UserCreditRepository;
/** SaaS-only team management: invitations, personal teams, seat caps, paid-subscription gating. */
@Service
@@ -43,11 +38,7 @@ public class SaasTeamService {
private final TeamMembershipRepository membershipRepository;
private final TeamInvitationRepository invitationRepository;
private final UserRepository userRepository;
private final UserCreditRepository userCreditRepository;
private final BillingSubscriptionRepository billingSubscriptionRepository;
private final TeamCreditService teamCreditService;
private final TeamCreditRepository teamCreditRepository;
private final CreditsProperties creditsProperties;
private final RestTemplate restTemplate;
private final RateLimitService rateLimitService;
private final SupabaseConfigurationProperties supabaseConfig;
@@ -59,6 +50,16 @@ public class SaasTeamService {
public static final String DEFAULT_TEAM_NAME = "Default";
public static final String INTERNAL_TEAM_NAME = "Internal";
/** Returns the user's personal team, creating one if they have none. Idempotent. */
@Transactional
public Team ensurePersonalTeam(User user) {
Team existing = user.getTeam();
if (existing != null && saasTeamExtensionService.isPersonal(existing)) {
return existing;
}
return createPersonalTeam(user);
}
/**
* Create personal team for new user during signup or migrate existing user from Default team
*
@@ -100,9 +101,6 @@ public class SaasTeamService {
user.setTeam(savedTeam);
userRepository.save(user);
// Initialize team credits
teamCreditService.initializeTeamCredits(savedTeam, user);
// Clean up old Default/Internal team membership
if (oldTeam != null
&& (DEFAULT_TEAM_NAME.equals(oldTeam.getName())
@@ -783,64 +781,6 @@ public class SaasTeamService {
teamRepository.save(team);
Optional<TeamCredit> creditOpt = teamCreditRepository.findByTeamId(teamId);
int fixedAllocation =
creditsProperties.getCycle().getAllocations().getOrDefault("ROLE_PRO_USER", 500);
if (creditOpt.isPresent()) {
TeamCredit credit = creditOpt.get();
int oldAllocation =
credit.getCycleCreditsAllocated() != null
? credit.getCycleCreditsAllocated()
: 0;
if (oldAllocation != fixedAllocation) {
int currentRemaining =
credit.getCycleCreditsRemaining() != null
? credit.getCycleCreditsRemaining()
: 0;
int allocationDifference = fixedAllocation - oldAllocation;
credit.setCycleCreditsAllocated(fixedAllocation);
int newRemaining = Math.max(0, currentRemaining + allocationDifference);
credit.setCycleCreditsRemaining(newRemaining);
teamCreditRepository.save(credit);
log.info(
"Updated team {} credit allocation: {} -> {} (fixed PRO amount). Remaining: {} -> {}",
teamId,
oldAllocation,
fixedAllocation,
currentRemaining,
newRemaining);
} else {
log.debug(
"Team {} already has fixed allocation of {} credits, no update needed",
teamId,
fixedAllocation);
}
} else {
log.warn("Team {} missing credit record; creating with fixed allocation", teamId);
TeamCredit credit = new TeamCredit(team);
credit.setCycleCreditsAllocated(fixedAllocation);
credit.setCycleCreditsRemaining(fixedAllocation);
credit.setBoughtCreditsRemaining(0);
credit.setTotalBoughtCredits(0);
credit.setTotalApiCallsMade(0L);
credit.setLastCycleResetAt(LocalDateTime.now());
teamCreditRepository.save(credit);
log.info(
"Created team_credits record for team {} with {} fixed credits (unlimited seats model)",
teamId,
fixedAllocation);
}
log.info(
"Team {} seat allocation updated: maxSeats={}, seatsUsed={}, isPersonal={}",
teamId,
@@ -31,6 +31,7 @@ public class SaasUserAccountService {
private final SupabaseUserService supabaseUserService;
private final SaasUserExtensionService saasUserExtensionService;
private final SaasTeamExtensionService saasTeamExtensionService;
private final SaasTeamService saasTeamService;
/**
* Resolve a local {@link User} from a Supabase UUID string. Throws if the ID format is invalid
@@ -173,6 +174,8 @@ public class SaasUserAccountService {
user.setUsername(email);
}
user = userService.saveUser(user);
// Give the upgraded user their own team rather than the shared Default team.
user.setTeam(saasTeamService.ensurePersonalTeam(user));
log.info(
"Upgraded anonymous user {} to {} ({})",
user.getId(),
@@ -1,279 +0,0 @@
package stirling.software.saas.service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.billing.service.StripeUsageReportingService;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.model.CreditConsumptionResult;
import stirling.software.saas.model.TeamCredit;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.repository.TeamCreditRepository;
import stirling.software.saas.repository.TeamMembershipRepository;
/**
* Service for managing team credit pools. Handles credit initialization, consumption, and cycle
* resets for teams.
*/
@Service
@Profile("saas")
@RequiredArgsConstructor
@Slf4j
public class TeamCreditService {
private final TeamCreditRepository teamCreditRepository;
private final TeamMembershipRepository membershipRepository;
private final CreditsProperties creditsProperties;
private final StripeUsageReportingService stripeUsageReportingService;
private final SaasUserExtensionService saasUserExtensionService;
/** Initialise a fixed PRO credit allocation for a new team. */
@Transactional
public TeamCredit initializeTeamCredits(Team team, User primaryUser) {
Optional<TeamCredit> existing = teamCreditRepository.findByTeamId(team.getId());
if (existing.isPresent()) {
log.debug("Team credits already exist for team {}", team.getId());
return existing.get();
}
TeamCredit credits = new TeamCredit(team);
// Fixed PRO allocation; seat-independent.
int proAllocation =
creditsProperties.getCycle().getAllocations().getOrDefault("ROLE_PRO_USER", 500);
int totalCycleAllocation = proAllocation;
credits.setCycleCreditsAllocated(totalCycleAllocation);
credits.setCycleCreditsRemaining(totalCycleAllocation);
credits.setLastCycleResetAt(LocalDateTime.now());
TeamCredit saved = teamCreditRepository.save(credits);
log.info(
"Initialized team credits for team {} with {} cycle credits (fixed PRO amount)",
team.getId(),
totalCycleAllocation);
return saved;
}
/**
* Check if team has credits available
*
* @param teamId the team ID
* @return true if team has credits available
*/
public boolean hasCreditsAvailable(Long teamId) {
return teamCreditRepository
.findByTeamId(teamId)
.map(TeamCredit::hasCreditsAvailable)
.orElse(false);
}
/**
* Atomically consume credits from team pool
*
* @param teamId the team ID
* @param amount number of credits to consume
* @return true if credits were consumed, false if insufficient credits or version conflict
*/
@Transactional
public boolean consumeCredit(Long teamId, int amount) {
int rowsUpdated = teamCreditRepository.consumeCredit(teamId, amount);
if (rowsUpdated == 0) {
log.warn(
"Failed to consume {} credits for team {} (insufficient credits or version conflict)",
amount,
teamId);
return false;
}
log.debug("Consumed {} credits for team {}", amount, teamId);
return true;
}
/**
* Get team credit summary for a user's team.
*
* @param user the user
* @return Optional of TeamCredit for the user's team
*/
public Optional<TeamCredit> getCreditSummaryForUser(User user) {
if (user.getTeam() == null) {
log.warn("User {} has no team assigned", user.getId());
return Optional.empty();
}
Long teamId = user.getTeam().getId();
log.debug("Using user's team {} for credit summary", teamId);
return teamCreditRepository.findByTeamId(teamId);
}
/**
* Get team credits by team ID
*
* @param teamId the team ID
* @return Optional of TeamCredit
*/
public Optional<TeamCredit> getTeamCredits(Long teamId) {
return teamCreditRepository.findByTeamId(teamId);
}
/**
* Add bought credits to team pool
*
* @param teamId the team ID
* @param credits number of credits to add
*/
@Transactional
public void addBoughtCredits(Long teamId, int credits) {
TeamCredit teamCredit =
teamCreditRepository
.findByTeamId(teamId)
.orElseThrow(() -> new IllegalArgumentException("Team credits not found"));
teamCredit.addBoughtCredits(credits);
teamCreditRepository.save(teamCredit);
log.info("Added {} bought credits to team {}", credits, teamId);
}
/**
* Reset cycle credits for team
*
* @param teamId the team ID
* @param cycleAllocation new cycle allocation
* @param resetTime reset timestamp
*/
@Transactional
public void resetCycleCredits(Long teamId, int cycleAllocation, LocalDateTime resetTime) {
TeamCredit teamCredit =
teamCreditRepository
.findByTeamId(teamId)
.orElseThrow(() -> new IllegalArgumentException("Team credits not found"));
teamCredit.resetCycleCredits(cycleAllocation, resetTime);
teamCreditRepository.save(teamCredit);
log.info("Reset cycle credits for team {} to {}", teamId, cycleAllocation);
}
/**
* Consume from the team credit pool; falls through to the team leader's metered Stripe billing
* when the pool is exhausted.
*/
@Transactional
public CreditConsumptionResult consumeCreditWithWaterfall(Long teamId, int amount) {
log.debug("[TEAM-CREDIT] Starting consumption for team {} - amount: {}", teamId, amount);
// Step 1: Try consuming from team credit pool
int rowsUpdated = teamCreditRepository.consumeCredit(teamId, amount);
if (rowsUpdated == 1) {
log.info("[TEAM-CREDIT] Consumed {} credits from team {} pool", amount, teamId);
return CreditConsumptionResult.success("TEAM_CREDITS");
}
log.warn("[TEAM-CREDIT] Team {} credit pool exhausted; checking leader overage", teamId);
// Step 2: Get team leader
Optional<User> leaderOpt = getTeamLeader(teamId);
if (leaderOpt.isEmpty()) {
log.error("[TEAM-CREDIT] Team {} has no leader; cannot use overage billing", teamId);
return CreditConsumptionResult.failure("NO_TEAM_LEADER");
}
User teamLeader = leaderOpt.get();
// Step 3: Check if team leader has metered billing enabled
if (!saasUserExtensionService.isMeteredBillingEnabled(teamLeader)) {
log.warn(
"[TEAM-CREDIT] Team {} leader {} does not have metered billing enabled",
teamId,
teamLeader.getUsername());
return CreditConsumptionResult.failure(
"TEAM_CREDITS_EXHAUSTED_NO_OVERAGE",
"Team credits exhausted. Team leader must enable overage billing for"
+ " uninterrupted service.");
}
// Step 4: Report overage to Stripe via team leader's metered billing
String leaderSupabaseId =
teamLeader.getSupabaseId() != null ? teamLeader.getSupabaseId().toString() : null;
if (leaderSupabaseId == null) {
log.error("[TEAM-CREDIT] Team leader {} has no Supabase ID", teamLeader.getUsername());
return CreditConsumptionResult.failure("LEADER_NO_SUPABASE_ID");
}
try {
String operationId = org.slf4j.MDC.get("requestId");
if (operationId == null || operationId.isBlank()) {
operationId = java.util.UUID.randomUUID().toString();
}
String idempotencyKey =
stripeUsageReportingService.generateIdempotencyKey(
leaderSupabaseId, amount, operationId);
log.info(
"[TEAM-CREDIT] Reporting {} overage credits to Stripe for team {} leader {}",
amount,
teamId,
teamLeader.getUsername());
boolean reported =
stripeUsageReportingService.reportUsageToStripe(
leaderSupabaseId, amount, idempotencyKey);
if (reported) {
log.info(
"[TEAM-CREDIT] Successfully reported {} overage credits for team {} via"
+ " leader {}",
amount,
teamId,
teamLeader.getUsername());
return CreditConsumptionResult.success("TEAM_LEADER_METERED");
} else {
log.error("[TEAM-CREDIT] Failed to report overage to Stripe for team {}", teamId);
return CreditConsumptionResult.failure(
"STRIPE_REPORTING_FAILED",
"Unable to report usage to Stripe. Please try again.");
}
} catch (Exception e) {
log.error(
"[TEAM-CREDIT] Exception reporting overage for team {}: {}",
teamId,
e.getMessage(),
e);
return CreditConsumptionResult.failure(
"STRIPE_REPORTING_ERROR", "Error reporting usage: " + e.getMessage());
}
}
/** Returns the team's LEADER (first one if multiple exist) for overage-billing routing. */
private Optional<User> getTeamLeader(Long teamId) {
List<TeamMembership> leaders =
membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER);
if (leaders.isEmpty()) {
log.warn("Team {} has no leaders", teamId);
return Optional.empty();
}
// Return first leader (typically only one leader per team)
TeamMembership leader = leaders.get(0);
User leaderUser = leader.getUser();
log.debug(
"Found team {} leader: {} (user ID: {})",
teamId,
leaderUser.getUsername(),
leaderUser.getId());
return Optional.of(leaderUser);
}
}
@@ -12,10 +12,9 @@ import stirling.software.proprietary.security.database.repository.AuthorityRepos
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.util.LogRedactionUtils;
/** Changes user roles and refreshes their credit allocation. */
/** Changes user roles (and the matching authority grant/revoke). */
@Service
@Profile("saas")
@RequiredArgsConstructor
@@ -24,8 +23,6 @@ public class UserRoleService {
private final UserRepository userRepository;
private final AuthorityRepository authorityRepository;
private final CreditService creditService;
private final CreditsProperties creditsProperties;
/**
* Change a user's role
@@ -58,7 +55,7 @@ public class UserRoleService {
/**
* Downgrade a user to FREE tier (ROLE_USER)
*
* <p>Changes role from PRO_USER to USER and resets cycle credit allocation to FREE tier.
* <p>Revokes ROLE_PRO_USER by changing the role/authority from PRO_USER to USER.
*
* @param user the user to downgrade
*/
@@ -70,24 +67,15 @@ public class UserRoleService {
changeRole(user, Role.USER.getRoleId());
// Reset credits to FREE tier allocation
int freeAllocation =
creditsProperties
.getCycle()
.getAllocations()
.getOrDefault(Role.USER.getRoleId(), 25);
creditService.resetCycleAllocationForRoleChange(user.getId(), freeAllocation);
log.info(
"Successfully downgraded user {} to FREE with {} cycle credits",
LogRedactionUtils.redactEmail(user.getUsername()),
freeAllocation);
"Successfully downgraded user {} to FREE",
LogRedactionUtils.redactEmail(user.getUsername()));
}
/**
* Upgrade a user to PRO tier (ROLE_PRO_USER)
*
* <p>Changes role from USER to PRO_USER and resets cycle credit allocation to PRO tier.
* <p>Grants ROLE_PRO_USER by changing the role/authority from USER to PRO_USER.
*
* @param user the user to upgrade
*/
@@ -98,30 +86,8 @@ public class UserRoleService {
changeRole(user, Role.PRO_USER.getRoleId());
// Reset credits to PRO tier allocation
int proAllocation =
creditsProperties
.getCycle()
.getAllocations()
.getOrDefault(Role.PRO_USER.getRoleId(), 100);
creditService.resetCycleAllocationForRoleChange(user.getId(), proAllocation);
log.info(
"Successfully upgraded user {} to PRO with {} cycle credits",
LogRedactionUtils.redactEmail(user.getUsername()),
proAllocation);
}
/**
* Get credit allocation for a specific role
*
* @param roleId the role ID (e.g., "ROLE_USER", "ROLE_PRO_USER")
* @return the cycle credit allocation for that role
*/
public int getCreditAllocationForRole(String roleId) {
return creditsProperties
.getCycle()
.getAllocations()
.getOrDefault(roleId, Role.USER.getRoleId().equals(roleId) ? 25 : 100);
"Successfully upgraded user {} to PRO",
LogRedactionUtils.redactEmail(user.getUsername()));
}
}
@@ -1,97 +0,0 @@
package stirling.software.saas.util;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.TeamCredit;
import stirling.software.saas.model.UserCredit;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.SaasTeamExtensionService;
import stirling.software.saas.service.TeamCreditService;
/**
* Resolves the user's remaining credit balance. Uses the team pool for non-personal team members,
* otherwise the user's individual credits (looked up by Supabase ID or API key).
*/
@Component
@Profile("saas")
@RequiredArgsConstructor
@Slf4j
public class CreditHeaderUtils {
private final SaasTeamExtensionService saasTeamExtensionService;
/**
* Get the remaining credits for a user, checking team credits first (non-personal teams only).
*
* @param user The user whose credits to check
* @param creditService The credit service to fetch user credits
* @param teamCreditService The team credit service to fetch team credits
* @return The remaining credit balance, or -1 if credits cannot be determined
*/
public int getRemainingCredits(
User user, CreditService creditService, TeamCreditService teamCreditService) {
try {
// Limited-API users always read personal credits.
boolean isLimitedApiUser =
user.getAuthorities().stream()
.anyMatch(
authority ->
"ROLE_LIMITED_API_USER".equals(authority.getAuthority())
|| "ROLE_EXTRA_LIMITED_API_USER"
.equals(authority.getAuthority()));
Long targetTeamId = null;
if (!isLimitedApiUser
&& user.getTeam() != null
&& !saasTeamExtensionService.isPersonal(user.getTeam())) {
targetTeamId = user.getTeam().getId();
}
if (targetTeamId != null) {
return teamCreditService
.getTeamCredits(targetTeamId)
.map(TeamCredit::getTotalAvailableCredits)
.orElse(-1);
} else {
log.debug(
"[CREDIT-HEADER] Getting personal credits - SupabaseId: {}, ApiKey: {}, Username: {}",
user.getSupabaseId(),
user.getApiKey() != null ? "present" : "null",
user.getUsername());
Optional<UserCredit> credits;
if (user.getSupabaseId() != null) {
credits =
creditService.getUserCreditsBySupabaseId(
user.getSupabaseId().toString());
log.debug(
"[CREDIT-HEADER] Looked up by SupabaseId - Found: {}",
credits.isPresent());
} else if (user.getApiKey() != null) {
credits = creditService.getUserCreditsByApiKey(user.getApiKey());
log.debug(
"[CREDIT-HEADER] Looked up by ApiKey - Found: {}", credits.isPresent());
} else {
log.warn(
"[CREDIT-HEADER] No SupabaseId or ApiKey for user: {}",
user.getUsername());
return -1;
}
int remaining = credits.map(UserCredit::getTotalAvailableCredits).orElse(-1);
log.debug("[CREDIT-HEADER] Returning credits: {}", remaining);
return remaining;
}
} catch (Exception e) {
log.warn("[CREDIT-HEADER] Could not get remaining credits: {}", e.getMessage(), e);
return -1;
}
}
}
@@ -64,17 +64,13 @@ import stirling.software.saas.payg.charge.JobChargeService;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.ProcessType;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.CreditHeaderUtils;
/**
* Pure unit tests for {@link AiCreateController}. All collaborators are mocked; the controller's
* handler methods are invoked directly and asserted via {@link ResponseEntity} / {@code verify}.
*
* <p>The controller reads {@code SecurityContextHolder} in the charge + credit-header paths, so
* each relevant test seeds an authentication and {@link #clearSecurityContext()} resets it
* afterwards.
* <p>The controller reads {@code SecurityContextHolder} in the charge path, so each relevant test
* seeds an authentication and {@link #clearSecurityContext()} resets it afterwards.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@@ -82,10 +78,7 @@ class AiCreateControllerTest {
@Mock private AiCreateSessionService sessionService;
@Mock private AiCreateProxyService proxyService;
@Mock private CreditService creditService;
@Mock private TeamCreditService teamCreditService;
@Mock private UserRepository userRepository;
@Mock private CreditHeaderUtils creditHeaderUtils;
@Mock private JobChargeService jobChargeService;
private AiCreateController controller;
@@ -94,13 +87,7 @@ class AiCreateControllerTest {
void setUp() {
controller =
new AiCreateController(
sessionService,
proxyService,
creditService,
teamCreditService,
userRepository,
creditHeaderUtils,
jobChargeService);
sessionService, proxyService, userRepository, jobChargeService);
}
@AfterEach
@@ -729,8 +716,6 @@ class AiCreateControllerTest {
// Ownership guard runs before the proxy.
verify(sessionService).getSessionForCurrentUser("sess-1");
assertThat(drain(resp.getBody())).isEqualTo("section data");
// No credit header on the non-AI-triggering endpoint.
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
}
@Test
@@ -761,7 +746,7 @@ class AiCreateControllerTest {
}
// ----------------------------------------------------------------------------------------------
// stream (proxy, accept event-stream + credit header)
// stream (proxy, accept event-stream)
// ----------------------------------------------------------------------------------------------
@Nested
@@ -769,13 +754,9 @@ class AiCreateControllerTest {
class Stream {
@Test
@DisplayName("proxies GET as event-stream and adds X-Credits-Remaining for authed user")
void stream_addsCreditHeader() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
.thenReturn(42);
@DisplayName(
"checks ownership, proxies GET as event-stream, defaults Content-Type, and streams")
void stream_proxiesEventStreamAndStreams() throws Exception {
HttpServletRequest req = mock(HttpServletRequest.class);
HttpResponse<InputStream> upstream =
upstreamResponse(200, "data: hi\n\n", httpHeaders(Map.of()));
@@ -789,80 +770,11 @@ class AiCreateControllerTest {
// No upstream Content-Type → defaulted to text/event-stream.
assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
.isEqualTo(MediaType.TEXT_EVENT_STREAM_VALUE);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("42");
// Ownership guard runs before the proxy.
verify(sessionService).getSessionForCurrentUser("sess-1");
assertThat(drain(resp.getBody())).isEqualTo("data: hi\n\n");
}
@Test
@DisplayName("negative remaining credits suppresses the credit header")
void stream_negativeCredits_omitsHeader() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
.thenReturn(-1);
HttpServletRequest req = mock(HttpServletRequest.class);
HttpResponse<InputStream> upstream = upstreamResponse(200, "x", httpHeaders(Map.of()));
when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream);
ResponseEntity<StreamingResponseBody> resp = controller.stream("sess-1", req);
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
}
@Test
@DisplayName("zero remaining credits still emits the header (>= 0 boundary)")
void stream_zeroCredits_emitsHeader() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
.thenReturn(0);
HttpServletRequest req = mock(HttpServletRequest.class);
HttpResponse<InputStream> upstream = upstreamResponse(200, "x", httpHeaders(Map.of()));
when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream);
ResponseEntity<StreamingResponseBody> resp = controller.stream("sess-1", req);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("0");
}
@Test
@DisplayName("unauthenticated context: stream still proxies, no credit header, no NPE")
void stream_noAuth_omitsCreditHeader() throws Exception {
SecurityContextHolder.clearContext();
HttpServletRequest req = mock(HttpServletRequest.class);
HttpResponse<InputStream> upstream = upstreamResponse(200, "x", httpHeaders(Map.of()));
when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream);
ResponseEntity<StreamingResponseBody> resp = controller.stream("sess-1", req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
verifyNoInteractions(creditHeaderUtils);
}
@Test
@DisplayName("credit-header lookup blowing up does not break the stream response")
void stream_creditLookupThrows_stillStreams() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(any(), any(), any()))
.thenThrow(new RuntimeException("boom"));
HttpServletRequest req = mock(HttpServletRequest.class);
HttpResponse<InputStream> upstream =
upstreamResponse(200, "payload", httpHeaders(Map.of()));
when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream);
ResponseEntity<StreamingResponseBody> resp = controller.stream("sess-1", req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
assertThat(drain(resp.getBody())).isEqualTo("payload");
}
@Test
@DisplayName("upstream non-2xx status is passed through; explicit Content-Type wins")
void stream_upstreamStatusAndExplicitContentTypePassedThrough() throws Exception {
@@ -904,7 +816,7 @@ class AiCreateControllerTest {
}
@Test
@DisplayName("ownership failure short-circuits before proxy/credit work")
@DisplayName("ownership failure short-circuits before proxying")
void stream_ownershipFailure_doesNotProxy() throws Exception {
HttpServletRequest req = mock(HttpServletRequest.class);
when(sessionService.getSessionForCurrentUser("sess-1"))
@@ -913,7 +825,6 @@ class AiCreateControllerTest {
assertThatThrownBy(() -> controller.stream("sess-1", req))
.isInstanceOf(ResponseStatusException.class);
verify(proxyService, never()).forward(any(), any(), any(), anyBoolean());
verifyNoInteractions(creditHeaderUtils);
}
}
@@ -6,7 +6,6 @@ import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
@@ -17,7 +16,6 @@ import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@@ -30,70 +28,41 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.ai.service.AiProxyService;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.CreditHeaderUtils;
/**
* Pure unit tests for {@link AiProxyController}. Every collaborator is mocked; each handler is
* invoked directly and asserted via {@link ResponseEntity} / {@code verify}.
*
* <p>All endpoints funnel through one private {@code proxy(method, path, request,
* acceptEventStream, includeCreditsHeader)} helper, so the suite has two halves:
* acceptEventStream)} helper, so the suite has two halves:
*
* <ol>
* <li>per-endpoint tests that pin the exact {@code (method, path, acceptEventStream)} contract a
* given handler forwards (the path-mapping surface), and
* <li>behavioural tests around the single shared {@code proxy} body: header copy, status
* resolution, the 503 error fallback, and the credit-header path keyed off {@code
* includeCreditsHeader}.
* resolution, and the 503 error fallback.
* </ol>
*
* <p>The credit-header branch reads {@code SecurityContextHolder}, so the relevant tests seed an
* authentication and {@link #clearSecurityContext()} resets it afterwards.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AiProxyControllerTest {
@Mock private AiProxyService aiProxyService;
@Mock private CreditService creditService;
@Mock private TeamCreditService teamCreditService;
@Mock private UserRepository userRepository;
@Mock private CreditHeaderUtils creditHeaderUtils;
private AiProxyController controller;
@org.junit.jupiter.api.BeforeEach
void setUp() {
controller =
new AiProxyController(
aiProxyService,
creditService,
teamCreditService,
userRepository,
creditHeaderUtils);
}
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
controller = new AiProxyController(aiProxyService);
}
// ----------------------------------------------------------------------------------------------
// Endpoint path/method mapping — each handler pins the exact upstream contract it forwards.
// None of these endpoints request the credit header except chat/* and edit message; see the
// dedicated credit-header section for those.
// ----------------------------------------------------------------------------------------------
@Nested
@@ -101,7 +70,7 @@ class AiProxyControllerTest {
class EndpointMapping {
@Test
@DisplayName("generateSection POSTs to /api/generate_section, non-stream, no credit header")
@DisplayName("generateSection POSTs to /api/generate_section, non-stream")
void generateSection() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/generate_section", req, false, ok("body"));
@@ -110,7 +79,6 @@ class AiProxyControllerTest {
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(aiProxyService).forward("POST", "/api/generate_section", req, false);
assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
}
@Test
@@ -135,6 +103,39 @@ class AiProxyControllerTest {
verify(aiProxyService).forward("POST", "/api/intent/check", req, false);
}
@Test
@DisplayName("chatRoute POSTs to /api/chat/route")
void chatRoute() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("body"));
controller.chatRoute(req);
verify(aiProxyService).forward("POST", "/api/chat/route", req, false);
}
@Test
@DisplayName("createSmartFolder POSTs to /api/chat/create-smart-folder")
void createSmartFolder() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/chat/create-smart-folder", req, false, ok("body"));
controller.createSmartFolder(req);
verify(aiProxyService).forward("POST", "/api/chat/create-smart-folder", req, false);
}
@Test
@DisplayName("chatInfo POSTs to /api/chat/info")
void chatInfo() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/chat/info", req, false, ok("body"));
controller.chatInfo(req);
verify(aiProxyService).forward("POST", "/api/chat/info", req, false);
}
@Test
@DisplayName("pdfAnswer POSTs to /api/pdf/answer")
void pdfAnswer() throws Exception {
@@ -212,6 +213,18 @@ class AiProxyControllerTest {
verify(aiProxyService).forward("POST", "/api/edit/sessions", req, false);
}
@Test
@DisplayName("editSessionMessage POSTs /api/edit/sessions/{id}/messages")
void editSessionMessage() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/edit/sessions/sess-9/messages", req, false, ok("body"));
controller.editSessionMessage("sess-9", req);
verify(aiProxyService)
.forward("POST", "/api/edit/sessions/sess-9/messages", req, false);
}
@Test
@DisplayName("editSessionAttachment POSTs /api/edit/sessions/{id}/attachments")
void editSessionAttachment() throws Exception {
@@ -228,7 +241,7 @@ class AiProxyControllerTest {
@DisplayName("runEditSession POSTs /api/edit/sessions/{id}/run as an event stream")
void runEditSession() throws Exception {
HttpServletRequest req = req();
// acceptEventStream == true here (and no credit header).
// acceptEventStream == true here.
stubForward("POST", "/api/edit/sessions/sess-9/run", req, true, ok("data: x\n\n"));
controller.runEditSession("sess-9", req);
@@ -505,234 +518,6 @@ class AiProxyControllerTest {
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(drain(resp.getBody())).contains("AI backend unavailable");
}
@Test
@DisplayName("a credit-bearing endpoint failing in forward never reaches the credit path")
void creditEndpointFailure_skipsCreditWork() throws Exception {
HttpServletRequest req = req();
authenticateWeb(userWithTeam(7L, 100L));
when(aiProxyService.forward(any(), any(), any(), anyBoolean()))
.thenThrow(new java.io.IOException("down"));
ResponseEntity<StreamingResponseBody> resp = controller.chatRoute(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
// The credit header is only added after a successful forward.
verifyNoInteractions(creditHeaderUtils);
assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
}
}
// ----------------------------------------------------------------------------------------------
// Credit headers — only the chat/* and edit-message endpoints set includeCreditsHeader = true.
// ----------------------------------------------------------------------------------------------
@Nested
@DisplayName(
"credit-header endpoints (chatRoute / createSmartFolder / chatInfo / editSessionMessage)")
class CreditHeaders {
@Test
@DisplayName(
"chatRoute POSTs /api/chat/route and adds X-Credits-Remaining for an authed user")
void chatRoute_addsCreditHeaders() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
.thenReturn(42);
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("reply"));
ResponseEntity<StreamingResponseBody> resp = controller.chatRoute(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("42");
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL");
verify(aiProxyService).forward("POST", "/api/chat/route", req, false);
}
@Test
@DisplayName("createSmartFolder POSTs /api/chat/create-smart-folder with the credit header")
void createSmartFolder_addsCreditHeaders() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
.thenReturn(5);
HttpServletRequest req = req();
stubForward("POST", "/api/chat/create-smart-folder", req, false, ok("folder"));
ResponseEntity<StreamingResponseBody> resp = controller.createSmartFolder(req);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("5");
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL");
verify(aiProxyService).forward("POST", "/api/chat/create-smart-folder", req, false);
}
@Test
@DisplayName("chatInfo POSTs /api/chat/info with the credit header")
void chatInfo_addsCreditHeaders() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
.thenReturn(3);
HttpServletRequest req = req();
stubForward("POST", "/api/chat/info", req, false, ok("info"));
ResponseEntity<StreamingResponseBody> resp = controller.chatInfo(req);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("3");
verify(aiProxyService).forward("POST", "/api/chat/info", req, false);
}
@Test
@DisplayName(
"editSessionMessage POSTs /api/edit/sessions/{id}/messages with the credit header")
void editSessionMessage_addsCreditHeaders() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
.thenReturn(99);
HttpServletRequest req = req();
stubForward("POST", "/api/edit/sessions/sess-9/messages", req, false, ok("msg"));
ResponseEntity<StreamingResponseBody> resp =
controller.editSessionMessage("sess-9", req);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("99");
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL");
verify(aiProxyService)
.forward("POST", "/api/edit/sessions/sess-9/messages", req, false);
}
@Test
@DisplayName("zero remaining credits still emits the header (>= 0 boundary)")
void zeroCredits_emitsHeader() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
.thenReturn(0);
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("reply"));
ResponseEntity<StreamingResponseBody> resp = controller.chatRoute(req);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("0");
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL");
}
@Test
@DisplayName(
"negative remaining credits omits X-Credits-Remaining but still sets the source")
void negativeCredits_omitsRemainingButKeepsSource() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
.thenReturn(-1);
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("reply"));
ResponseEntity<StreamingResponseBody> resp = controller.chatRoute(req);
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
// X-Credit-Source is set unconditionally once we reach the credit path.
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL");
}
@Test
@DisplayName(
"no authentication: credit work is skipped, no credit headers, response still streams")
void noAuth_skipsCreditHeaders() throws Exception {
SecurityContextHolder.clearContext();
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("reply"));
ResponseEntity<StreamingResponseBody> resp = controller.chatRoute(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
verifyNoInteractions(creditHeaderUtils);
assertThat(drain(resp.getBody())).isEqualTo("reply");
}
@Test
@DisplayName("an unauthenticated token (isAuthenticated()==false) is treated as no-auth")
void unauthenticatedToken_skipsCreditHeaders() throws Exception {
// 2-arg ctor → isAuthenticated() == false, so the credit branch short-circuits.
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken("alice", "pw");
SecurityContextHolder.getContext().setAuthentication(token);
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("reply"));
ResponseEntity<StreamingResponseBody> resp = controller.chatRoute(req);
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
verifyNoInteractions(creditHeaderUtils);
}
@Test
@DisplayName("credit-header work blowing up is swallowed; the response still streams")
void creditLookupThrows_stillStreams() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(any(), any(), any()))
.thenThrow(new RuntimeException("boom"));
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("payload"));
ResponseEntity<StreamingResponseBody> resp = controller.chatRoute(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
// The catch in addCreditHeaders runs before X-Credit-Source is set, so neither lands.
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
assertThat(drain(resp.getBody())).isEqualTo("payload");
}
@Test
@DisplayName("resolving the current user failing is swallowed; the response still streams")
void getCurrentUserThrows_stillStreams() throws Exception {
// EnhancedJwtAuthenticationToken is the only auth type with a getCurrentUser DB lookup,
// but a plain authed token whose principal is a User short-circuits there. To exercise
// the swallow we make the downstream credit lookup throw (covered above); here we
// assert
// that a non-User principal that can't be resolved doesn't break the stream.
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken("alice", "pw", List.of());
// principal is the String "alice"; getCurrentUser will hit
// userRepository.findByUsername.
when(userRepository.findByUsername("alice")).thenReturn(java.util.Optional.empty());
SecurityContextHolder.getContext().setAuthentication(token);
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("payload"));
ResponseEntity<StreamingResponseBody> resp = controller.chatRoute(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
assertThat(drain(resp.getBody())).isEqualTo("payload");
verifyNoInteractions(creditHeaderUtils);
}
@Test
@DisplayName(
"an authed User principal is passed straight to the credit utils (no repo lookup)")
void authedUserPrincipal_passedToCreditUtils() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(any(), any(), any())).thenReturn(11);
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("reply"));
controller.chatRoute(req);
// The principal User is forwarded verbatim alongside both credit services.
verify(creditHeaderUtils).getRemainingCredits(user, creditService, teamCreditService);
verifyNoInteractions(userRepository);
}
}
// ----------------------------------------------------------------------------------------------
@@ -759,24 +544,6 @@ class AiProxyControllerTest {
return upstreamResponse(200, body, httpHeaders(Map.of()));
}
private static User userWithTeam(long userId, long teamId) {
User user = new User();
user.setId(userId);
Team team = new Team();
team.setId(teamId);
user.setTeam(team);
return user;
}
/**
* Authenticated WEB principal: 3-arg ctor so isAuthenticated()==true, principal is the User.
*/
private static void authenticateWeb(User user) {
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(user, null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
}
private static java.net.http.HttpHeaders httpHeaders(Map<String, String> single) {
Map<String, List<String>> multi = new java.util.HashMap<>();
single.forEach((k, v) -> multi.put(k, List.of(v)));
@@ -1,89 +0,0 @@
package stirling.software.saas.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.UUID;
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 stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.CreditService.CreditSummary;
/**
* Regression coverage for finding #15: API-key users used to always see empty credits because the
* controller blindly passed the API key string through to {@code getCreditSummaryBySupabaseId},
* which then blew up on {@code UUID.fromString}. The new code reads the User from the principal and
* prefers the linked Supabase ID, falling back to API-key-keyed credits.
*/
@ExtendWith(MockitoExtension.class)
class CreditControllerApiKeyTest {
@Mock private CreditService creditService;
@Test
void apiKeyUserWithSupabaseIdGetsResolvedToSupabaseLookup() {
UUID supabaseId = UUID.randomUUID();
User u = new User();
u.setSupabaseId(supabaseId);
CreditSummary expected = creditSummary(42, 100);
when(creditService.getCreditSummaryBySupabaseId(supabaseId.toString()))
.thenReturn(expected);
CreditController controller = new CreditController(creditService);
ApiKeyAuthenticationToken token =
new ApiKeyAuthenticationToken(u, "the-api-key", java.util.List.of());
ResponseEntity<CreditSummary> resp = controller.getUserCredits(token);
assertThat(resp.getBody()).isSameAs(expected);
verify(creditService).getCreditSummaryBySupabaseId(supabaseId.toString());
}
@Test
void apiKeyUserWithoutSupabaseIdFallsBackToApiKeyLookup() {
User u = new User();
// No supabaseId set — covers self-hosted / OSS-style API-only users.
CreditSummary expected = creditSummary(7, 25);
when(creditService.getCreditSummaryByApiKey("apikey-no-supabase")).thenReturn(expected);
CreditController controller = new CreditController(creditService);
ApiKeyAuthenticationToken token =
new ApiKeyAuthenticationToken(u, "apikey-no-supabase", java.util.List.of());
ResponseEntity<CreditSummary> resp = controller.getUserCredits(token);
assertThat(resp.getBody()).isSameAs(expected);
verify(creditService).getCreditSummaryByApiKey(eq("apikey-no-supabase"));
}
@Test
void apiKeyTokenWithoutUserPrincipalFallsBackToApiKeyLookup() {
// Edge: token wasn't constructed with a User principal. Should still attempt API-key
// lookup rather than throw.
CreditSummary expected = creditSummary(0, 0);
when(creditService.getCreditSummaryByApiKey("orphan-key")).thenReturn(expected);
CreditController controller = new CreditController(creditService);
ApiKeyAuthenticationToken token =
new ApiKeyAuthenticationToken("not-a-user", "orphan-key", java.util.List.of());
ResponseEntity<CreditSummary> resp = controller.getUserCredits(token);
assertThat(resp.getBody()).isNotNull();
verify(creditService).getCreditSummaryByApiKey("orphan-key");
}
private static CreditSummary creditSummary(int remaining, int allocated) {
return new CreditSummary(remaining, allocated, 0, 0, remaining, null, null, false);
}
}
@@ -1,725 +0,0 @@
package stirling.software.saas.interceptor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.CreditConsumptionResult;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.ErrorTrackingService;
import stirling.software.saas.service.SaasTeamExtensionService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.CreditHeaderUtils;
/**
* Unit tests for {@link CreditErrorAdvice}.
*
* <p>The advice is a {@code @RestControllerAdvice} that maps thrown exceptions to a status/body
* and, when the request was flagged eligible (and not already charged), consumes a credit through
* either the team pool or the individual waterfall. Collaborators are mocked; the {@link
* MeterRegistry} is a real {@link SimpleMeterRegistry} so the {@code credits.consumed} counter is
* exercised.
*
* <p>Authentication is driven through {@link SecurityContextHolder} using a 3-arg {@code
* UsernamePasswordAuthenticationToken} (authenticated=true) whose principal is the live {@link
* User} object, so {@code AuthenticationUtils.getCurrentUser} resolves it directly via {@code
* instanceof User} without touching the repository.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CreditErrorAdviceTest {
private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE";
private static final String ATTR_APIKEY = "CREDIT_API_KEY";
private static final String ATTR_CHARGED = "CREDIT_CHARGED";
private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
private static final String ATTR_IS_API = "IS_API_REQUEST";
private static final String API_KEY = "apikey-abcdefgh";
private static final String URI = "/api/v1/convert/pdf-to-img";
@Mock private CreditService creditService;
@Mock private TeamCreditService teamCreditService;
@Mock private UserRepository userRepository;
@Mock private ErrorTrackingService errorTrackingService;
@Mock private SaasTeamExtensionService saasTeamExtensionService;
@Mock private CreditHeaderUtils creditHeaderUtils;
private MeterRegistry meterRegistry;
private CreditErrorAdvice advice;
@BeforeEach
void setUp() {
meterRegistry = new SimpleMeterRegistry();
advice =
new CreditErrorAdvice(
creditService,
teamCreditService,
userRepository,
errorTrackingService,
saasTeamExtensionService,
creditHeaderUtils,
meterRegistry);
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
// --- helpers --------------------------------------------------------------------------------
private static User user(String username) {
User u = new User();
u.setUsername(username);
return u;
}
private static Team team(Long id) {
Team t = new Team();
t.setId(id);
return t;
}
/** Put the user on the SecurityContext as an authenticated principal. */
private void authenticate(User user) {
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(
user, null, List.of(new SimpleGrantedAuthority("ROLE_USER")));
SecurityContextHolder.getContext().setAuthentication(token);
}
/** Base request that is credit-eligible with an api key, resource weight 1 and not charged. */
private MockHttpServletRequest eligibleRequest() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI(URI);
req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
req.setAttribute(ATTR_APIKEY, API_KEY);
req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(1));
return req;
}
@SuppressWarnings("unchecked")
private static Map<String, Object> bodyOf(ResponseEntity<Object> resp) {
return (Map<String, Object>) resp.getBody();
}
private double counter() {
return meterRegistry.get("credits.consumed").counter().count();
}
// --- status mapping -------------------------------------------------------------------------
@Nested
@DisplayName("determineHttpStatus mapping (via handleThrowable)")
class StatusMapping {
@Test
@DisplayName("IllegalArgumentException -> 400 BAD_REQUEST")
void illegalArgument_400() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp =
advice.handleThrowable(req, new IllegalArgumentException("bad"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(bodyOf(resp))
.containsEntry("error", "IllegalArgumentException")
.containsEntry("message", "bad")
.containsEntry("status", 400);
}
@Test
@DisplayName("AccessDeniedException -> 403 FORBIDDEN")
void accessDenied_403() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp =
advice.handleThrowable(
req,
new org.springframework.security.access.AccessDeniedException("nope"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
@DisplayName("UsernameNotFoundException -> 401 UNAUTHORIZED")
void usernameNotFound_401() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp =
advice.handleThrowable(
req,
new org.springframework.security.core.userdetails
.UsernameNotFoundException("who"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@DisplayName("UnsupportedOperationException -> 501 NOT_IMPLEMENTED")
void unsupported_501() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp =
advice.handleThrowable(req, new UnsupportedOperationException("later"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_IMPLEMENTED);
}
@Test
@DisplayName("unknown exception with no message clue -> 500 INTERNAL_SERVER_ERROR")
void unknown_500() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("boom"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
}
@Test
@DisplayName("message containing 'not found' -> 404 NOT_FOUND")
void messageNotFound_404() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp =
advice.handleThrowable(req, new RuntimeException("Resource not found here"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
@DisplayName("message containing 'validation' -> 400 BAD_REQUEST")
void messageValidation_400() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp =
advice.handleThrowable(req, new RuntimeException("Validation of input failed"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("message containing 'invalid parameter' -> 400 BAD_REQUEST")
void messageInvalidParameter_400() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp =
advice.handleThrowable(req, new RuntimeException("invalid parameter: x"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("null message falls back to 'An error occurred' and 500")
void nullMessage_default() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException());
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(bodyOf(resp)).containsEntry("message", "An error occurred");
}
}
// --- no credit handling (gates closed) ------------------------------------------------------
@Nested
@DisplayName("credit handling gate is closed -> no consumption")
class GateClosed {
@Test
@DisplayName("request not eligible: no error tracking, no consumption")
void notEligible_noConsumption() {
MockHttpServletRequest req = new MockHttpServletRequest();
// ATTR_ELIGIBLE absent
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
verifyNoInteractions(errorTrackingService, creditService, teamCreditService);
assertThat(counter()).isZero();
}
@Test
@DisplayName("eligible but already charged and unauthenticated: no consumption, no header")
void alreadyCharged_unauthenticated_noHeader() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_CHARGED, Boolean.TRUE);
// no SecurityContext authentication
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
verifyNoInteractions(errorTrackingService, creditService, teamCreditService);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
assertThat(counter()).isZero();
}
@Test
@DisplayName(
"eligible, null api key: error tracking is skipped entirely (apiKey != null guard)")
void eligibleNullApiKey_noTracking() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI(URI);
req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(1));
// no ATTR_APIKEY -> apiKey is null
advice.handleThrowable(req, new RuntimeException("x"));
verifyNoInteractions(errorTrackingService, creditService, teamCreditService);
assertThat(counter()).isZero();
}
@Test
@DisplayName("eligible with api key but tracking says do NOT consume: no charge")
void trackingSaysNo_noConsumption() {
MockHttpServletRequest req = eligibleRequest();
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(false);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
verify(errorTrackingService)
.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), eq(500));
verifyNoInteractions(creditService, teamCreditService);
assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
assertThat(counter()).isZero();
}
}
// --- individual (waterfall) consumption -----------------------------------------------------
@Nested
@DisplayName("individual credit consumption (no team)")
class IndividualConsumption {
@Test
@DisplayName("waterfall success: marks charged, increments counter, sets both headers")
void waterfallSuccess_chargesAndHeaders() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(3));
req.setAttribute(ATTR_IS_API, Boolean.TRUE);
User u = user("alice");
authenticate(u);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(creditService.consumeCreditWithWaterfall(u, 3, true))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(42);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
assertThat(counter()).isEqualTo(1.0d);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("42");
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("CYCLE_CREDITS");
verify(creditService).consumeCreditWithWaterfall(u, 3, true);
verify(teamCreditService, never()).consumeCredit(any(), anyInt());
}
@Test
@DisplayName("resource weight absent defaults credit amount to 1")
void weightAbsent_defaultsToOne() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI(URI);
req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
req.setAttribute(ATTR_APIKEY, API_KEY);
// no resource weight, no IS_API_REQUEST -> isApiRequestFlag false
User u = user("bob");
authenticate(u);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("BOUGHT_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(0);
advice.handleThrowable(req, new RuntimeException("x"));
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
}
@Test
@DisplayName("waterfall failure: not charged, counter stays zero, no headers")
void waterfallFailure_notCharged() {
MockHttpServletRequest req = eligibleRequest();
User u = user("carol");
authenticate(u);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.failure("INSUFFICIENT_CREDITS"));
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
assertThat(counter()).isZero();
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isNull();
}
@Test
@DisplayName("success but negative remaining: charged + source header, no remaining header")
void successNegativeRemaining_noRemainingHeader() {
MockHttpServletRequest req = eligibleRequest();
User u = user("dave");
authenticate(u);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("METERED_SUBSCRIPTION"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(-1);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
assertThat(counter()).isEqualTo(1.0d);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
assertThat(resp.getHeaders().getFirst("X-Credit-Source"))
.isEqualTo("METERED_SUBSCRIPTION");
}
@Test
@DisplayName("success with null source: charged, remaining header set, no source header")
void successNullSource_noSourceHeader() {
MockHttpServletRequest req = eligibleRequest();
User u = user("erin");
authenticate(u);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
// success=true but source=null (unusual but defensively handled by the advice)
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(new CreditConsumptionResult(true, null, "ok"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(7);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("7");
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isNull();
}
@Test
@DisplayName("personal team is treated as no team -> waterfall, not team pool")
void personalTeam_usesWaterfall() {
MockHttpServletRequest req = eligibleRequest();
User u = user("frank");
u.setTeam(team(99L));
authenticate(u);
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(true);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(5);
advice.handleThrowable(req, new RuntimeException("x"));
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
verify(teamCreditService, never()).consumeCredit(any(), anyInt());
}
}
// --- team consumption -----------------------------------------------------------------------
@Nested
@DisplayName("team credit consumption (non-personal team)")
class TeamConsumption {
@Test
@DisplayName("non-personal team success: consumes from team pool, source TEAM_CREDITS")
void teamSuccess_consumesTeamPool() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(2));
User u = user("gina");
u.setTeam(team(77L));
authenticate(u);
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(teamCreditService.consumeCredit(77L, 2)).thenReturn(true);
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(100);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
assertThat(counter()).isEqualTo(1.0d);
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("TEAM_CREDITS");
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("100");
verify(teamCreditService).consumeCredit(77L, 2);
verify(creditService, never())
.consumeCreditWithWaterfall(any(), anyInt(), anyBoolean());
}
@Test
@DisplayName("non-personal team failure: not charged, counter stays zero")
void teamFailure_notCharged() {
MockHttpServletRequest req = eligibleRequest();
User u = user("hank");
u.setTeam(team(55L));
authenticate(u);
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(teamCreditService.consumeCredit(55L, 1)).thenReturn(false);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
assertThat(counter()).isZero();
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isNull();
verify(creditService, never())
.consumeCreditWithWaterfall(any(), anyInt(), anyBoolean());
}
}
// --- user resolution edge cases -------------------------------------------------------------
@Nested
@DisplayName("user resolution edge cases (tracking says consume)")
class UserResolution {
@Test
@DisplayName("no authentication: getCurrentUser throws, user null -> no consumption")
void noAuth_userNull_noConsumption() {
MockHttpServletRequest req = eligibleRequest();
// no SecurityContext authentication -> AuthenticationUtils throws SecurityException
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
assertThat(counter()).isZero();
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
verifyNoInteractions(creditService, teamCreditService);
}
}
// --- already-charged header path ------------------------------------------------------------
@Nested
@DisplayName("already-charged path sets remaining header when authenticated")
class AlreadyCharged {
@Test
@DisplayName(
"already charged + authenticated + non-negative remaining: header set, no consumption")
void alreadyCharged_authenticated_setsHeader() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_CHARGED, Boolean.TRUE);
User u = user("ian");
authenticate(u);
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(9);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("9");
// Already-charged branch never records an error or consumes again.
verifyNoInteractions(errorTrackingService, teamCreditService);
verify(creditService, never())
.consumeCreditWithWaterfall(any(), anyInt(), anyBoolean());
assertThat(counter()).isZero();
}
@Test
@DisplayName("already charged + authenticated + negative remaining: no header")
void alreadyCharged_negativeRemaining_noHeader() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_CHARGED, Boolean.TRUE);
User u = user("jane");
authenticate(u);
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(-1);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
}
@Test
@DisplayName("already charged: header lookup throwing is swallowed (no propagation)")
void alreadyCharged_headerLookupThrows_swallowed() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_CHARGED, Boolean.TRUE);
User u = user("kyle");
authenticate(u);
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenThrow(new RuntimeException("header boom"));
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
// Must not propagate; status still computed and no remaining header set.
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
}
}
// --- SSE response shaping --------------------------------------------------------------------
@Nested
@DisplayName("SSE response shaping")
class SseShaping {
@Test
@DisplayName("Accept text/event-stream: body is SSE framed text with event: error")
void acceptHeader_producesSseBody() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.addHeader("Accept", MediaType.TEXT_EVENT_STREAM_VALUE);
ResponseEntity<Object> resp =
advice.handleThrowable(req, new IllegalArgumentException("bad"));
assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM);
assertThat(resp.getBody()).isInstanceOf(String.class);
String sse = (String) resp.getBody();
assertThat(sse).startsWith("event: error\ndata: ").endsWith("\n\n");
assertThat(sse).contains("\"error\":\"IllegalArgumentException\"");
assertThat(sse).contains("\"status\":400");
}
@Test
@DisplayName("Content-Type text/event-stream also triggers SSE framing")
void contentTypeHeader_producesSseBody() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("boom"));
assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM);
assertThat((String) resp.getBody()).startsWith("event: error\ndata: ");
}
@Test
@DisplayName("non-SSE request returns the Map body, not SSE text")
void nonSse_returnsMapBody() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.addHeader("Accept", MediaType.APPLICATION_JSON_VALUE);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("boom"));
assertThat(resp.getBody()).isInstanceOf(Map.class);
assertThat(resp.getHeaders().getContentType()).isNull();
}
@Test
@DisplayName(
"SSE framing carries through after a successful team charge (headers + SSE body)")
void sseWithTeamCharge_headersAndSseBody() {
MockHttpServletRequest req = eligibleRequest();
req.addHeader("Accept", MediaType.TEXT_EVENT_STREAM_VALUE);
User u = user("liz");
u.setTeam(team(33L));
authenticate(u);
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(teamCreditService.consumeCredit(33L, 1)).thenReturn(true);
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(8);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM);
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("TEAM_CREDITS");
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("8");
assertThat((String) resp.getBody()).startsWith("event: error\ndata: ");
}
}
// --- counter accumulation across calls -------------------------------------------------------
@Test
@DisplayName("counter accumulates across multiple successful charges")
void counterAccumulates() {
User u = user("mike");
authenticate(u);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(creditService.consumeCreditWithWaterfall(eq(u), eq(1), eq(false)))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(3);
advice.handleThrowable(eligibleRequest(), new RuntimeException("a"));
advice.handleThrowable(eligibleRequest(), new RuntimeException("b"));
assertThat(counter()).isEqualTo(2.0d);
verify(creditService, times(2)).consumeCreditWithWaterfall(u, 1, false);
}
@Test
@DisplayName("ErrorResponse value holder wires its fields verbatim")
void errorResponseHolder() {
CreditErrorAdvice.ErrorResponse er =
new CreditErrorAdvice.ErrorResponse("Boom", "it broke", 500);
assertThat(er.error).isEqualTo("Boom");
assertThat(er.message).isEqualTo("it broke");
assertThat(er.status).isEqualTo(500);
}
}
@@ -1,679 +0,0 @@
package stirling.software.saas.interceptor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.CreditConsumptionResult;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.SaasTeamExtensionService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.CreditHeaderUtils;
/**
* Unit tests for {@link CreditSuccessAdvice}.
*
* <p>The advice is a {@code @RestControllerAdvice} {@code ResponseBodyAdvice} that, on a successful
* (status &lt; 400) response previously flagged credit-eligible (and not already charged), consumes
* a credit either from the team pool (non-personal team) or via the individual waterfall, then sets
* {@code X-Credits-Remaining} / {@code X-Credit-Source} headers and increments a {@code
* credits.consumed} counter. Collaborators are mocked; the {@link MeterRegistry} is a real {@link
* SimpleMeterRegistry} so the counter is exercised.
*
* <p>{@link ServerHttpRequest}/response are constructed by wrapping {@link MockHttpServletRequest}
* and {@link MockHttpServletResponse} in {@link ServletServerHttpRequest}/{@link
* ServletServerHttpResponse} so the advice's {@code instanceof} unwrapping and status read work
* against the mock servlet objects. {@code beforeBodyWrite}'s {@code MethodParameter}, {@code
* MediaType} and converter type arguments are unused by the charging logic, so {@code null} is
* passed for them.
*
* <p>Authentication is driven through {@link SecurityContextHolder} using a 3-arg {@code
* UsernamePasswordAuthenticationToken} (authenticated=true) whose principal is the live {@link
* User} object, so {@code AuthenticationUtils.getCurrentUser} resolves it directly via {@code
* instanceof User} without touching the repository. The authorities on that token drive the
* limited-API-user check (which the advice reads from the authentication, not the user).
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CreditSuccessAdviceTest {
private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE";
private static final String ATTR_APIKEY = "CREDIT_API_KEY";
private static final String ATTR_CHARGED = "CREDIT_CHARGED";
private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
private static final String ATTR_IS_API = "IS_API_REQUEST";
private static final String API_KEY = "apikey-abcdefgh";
@Mock private CreditService creditService;
@Mock private TeamCreditService teamCreditService;
@Mock private UserRepository userRepository;
@Mock private SaasTeamExtensionService saasTeamExtensionService;
@Mock private CreditHeaderUtils creditHeaderUtils;
private MeterRegistry meterRegistry;
private CreditSuccessAdvice advice;
@BeforeEach
void setUp() {
meterRegistry = new SimpleMeterRegistry();
advice =
new CreditSuccessAdvice(
creditService,
teamCreditService,
userRepository,
saasTeamExtensionService,
creditHeaderUtils,
meterRegistry);
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
// --- helpers --------------------------------------------------------------------------------
private static User user(String username) {
User u = new User();
u.setUsername(username);
return u;
}
private static Team team(Long id) {
Team t = new Team();
t.setId(id);
return t;
}
/** Authenticate with the default ROLE_USER authority (not a limited-API user). */
private void authenticate(User user) {
authenticate(user, "ROLE_USER");
}
/** Put the user on the SecurityContext as an authenticated principal with given authorities. */
private void authenticate(User user, String... authorities) {
var grants = java.util.Arrays.stream(authorities).map(SimpleGrantedAuthority::new).toList();
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(user, null, grants);
SecurityContextHolder.getContext().setAuthentication(token);
}
/** Base request: credit-eligible with an api key, resource weight 1, not charged. */
private MockHttpServletRequest eligibleRequest() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
req.setAttribute(ATTR_APIKEY, API_KEY);
req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(1));
return req;
}
/** Holder so a test can read back both the returned body and the underlying servlet objects. */
private static final class Exchange {
final MockHttpServletRequest servletReq;
final MockHttpServletResponse servletResp;
final ServletServerHttpResponse response;
Exchange(MockHttpServletRequest servletReq, MockHttpServletResponse servletResp) {
this.servletReq = servletReq;
this.servletResp = servletResp;
this.response = new ServletServerHttpResponse(servletResp);
}
String header(String name) {
// Read from the live ServerHttpResponse headers the advice wrote to.
return response.getHeaders().getFirst(name);
}
}
/**
* Invoke beforeBodyWrite against the given servlet request/response and return the exchange.
*/
private Object invoke(Exchange ex, Object body) {
ServletServerHttpRequest request = new ServletServerHttpRequest(ex.servletReq);
return advice.beforeBodyWrite(body, null, null, null, request, ex.response);
}
private Object invoke(MockHttpServletRequest servletReq, Object body) {
return invoke(new Exchange(servletReq, new MockHttpServletResponse()), body);
}
private double counter() {
return meterRegistry.get("credits.consumed").counter().count();
}
// --- supports() -----------------------------------------------------------------------------
@Test
@DisplayName("supports() returns true for any return type / converter")
void supports_alwaysTrue() {
assertThat(advice.supports(null, null)).isTrue();
}
// --- gates that short-circuit (body returned unchanged, nothing consumed) --------------------
@Nested
@DisplayName("short-circuit gates -> body returned unchanged, no consumption")
class Gates {
@Test
@DisplayName("non-servlet request: returns body untouched, no interactions")
void nonServletRequest_returnsBody() {
ServerHttpRequest notServlet = org.mockito.Mockito.mock(ServerHttpRequest.class);
ServletServerHttpResponse resp =
new ServletServerHttpResponse(new MockHttpServletResponse());
Object body = "BODY";
Object out = advice.beforeBodyWrite(body, null, null, null, notServlet, resp);
assertThat(out).isSameAs(body);
verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
assertThat(counter()).isZero();
}
@Test
@DisplayName("not eligible (attribute absent): no consumption")
void notEligible_noConsumption() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setAttribute(ATTR_APIKEY, API_KEY);
Object out = invoke(req, "BODY");
assertThat(out).isEqualTo("BODY");
verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
assertThat(counter()).isZero();
}
@Test
@DisplayName("eligible attribute not Boolean.TRUE (e.g. Boolean.FALSE): no consumption")
void eligibleFalse_noConsumption() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_ELIGIBLE, Boolean.FALSE);
authenticate(user("x"));
invoke(req, "BODY");
verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
assertThat(counter()).isZero();
}
@Test
@DisplayName("already charged (CREDIT_CHARGED set): no second consumption")
void alreadyCharged_noConsumption() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_CHARGED, Boolean.TRUE);
authenticate(user("x"));
invoke(req, "BODY");
verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
assertThat(counter()).isZero();
}
@Test
@DisplayName("error status >= 400 on response: skip consumption, error advice decides")
void errorStatus_skipsConsumption() {
MockHttpServletRequest req = eligibleRequest();
authenticate(user("x"));
MockHttpServletResponse servletResp = new MockHttpServletResponse();
servletResp.setStatus(500);
Exchange ex = new Exchange(req, servletResp);
Object out = invoke(ex, "BODY");
assertThat(out).isEqualTo("BODY");
verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
assertThat(counter()).isZero();
}
@Test
@DisplayName("status exactly 400 is treated as error -> skip")
void status400_skipsConsumption() {
MockHttpServletRequest req = eligibleRequest();
authenticate(user("x"));
MockHttpServletResponse servletResp = new MockHttpServletResponse();
servletResp.setStatus(400);
invoke(new Exchange(req, servletResp), "BODY");
verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
assertThat(counter()).isZero();
}
@Test
@DisplayName("status 399 (just below 400) still consumes")
void status399_stillConsumes() {
MockHttpServletRequest req = eligibleRequest();
User u = user("edge");
authenticate(u);
MockHttpServletResponse servletResp = new MockHttpServletResponse();
servletResp.setStatus(399);
Exchange ex = new Exchange(req, servletResp);
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(5);
invoke(ex, "BODY");
assertThat(counter()).isEqualTo(1.0d);
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
}
@Test
@DisplayName("eligible but apiKey attribute absent: no consumption (apiKey != null guard)")
void nullApiKey_noConsumption() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(1));
// no ATTR_APIKEY -> apiKey null
authenticate(user("x"));
invoke(req, "BODY");
verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
assertThat(counter()).isZero();
}
}
// --- individual (waterfall) consumption -----------------------------------------------------
@Nested
@DisplayName("individual credit consumption (no team)")
class IndividualConsumption {
@Test
@DisplayName("waterfall success: marks charged, increments counter, sets both headers")
void waterfallSuccess_chargesAndHeaders() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(3));
req.setAttribute(ATTR_IS_API, Boolean.TRUE);
User u = user("alice");
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(creditService.consumeCreditWithWaterfall(u, 3, true))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(42);
Object out = invoke(ex, "BODY");
assertThat(out).isEqualTo("BODY");
assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
assertThat(counter()).isEqualTo(1.0d);
assertThat(ex.header("X-Credits-Remaining")).isEqualTo("42");
assertThat(ex.header("X-Credit-Source")).isEqualTo("CYCLE_CREDITS");
verify(creditService).consumeCreditWithWaterfall(u, 3, true);
verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt());
}
@Test
@DisplayName("resource weight absent defaults credit amount to 1, IS_API absent -> false")
void weightAbsent_defaultsToOne() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
req.setAttribute(ATTR_APIKEY, API_KEY);
// no resource weight, no IS_API_REQUEST -> isApiRequestFlag false
User u = user("bob");
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("BOUGHT_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(0);
invoke(ex, "BODY");
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
// remaining 0 is non-negative -> header set
assertThat(ex.header("X-Credits-Remaining")).isEqualTo("0");
assertThat(ex.header("X-Credit-Source")).isEqualTo("BOUGHT_CREDITS");
}
@Test
@DisplayName("IS_API_REQUEST=false is passed through as false to the waterfall")
void isApiFalse_passedThrough() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_IS_API, Boolean.FALSE);
User u = user("ivy");
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(3);
invoke(ex, "BODY");
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
}
@Test
@DisplayName(
"waterfall failure (insufficient credits): not charged, counter zero, no headers")
void waterfallFailure_notCharged() {
MockHttpServletRequest req = eligibleRequest();
User u = user("carol");
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.failure("INSUFFICIENT_CREDITS"));
invoke(ex, "BODY");
assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
assertThat(counter()).isZero();
assertThat(ex.header("X-Credits-Remaining")).isNull();
assertThat(ex.header("X-Credit-Source")).isNull();
// header utils is only consulted after a successful charge
verifyNoInteractions(creditHeaderUtils);
}
@Test
@DisplayName("success but negative remaining: charged + source header, no remaining header")
void successNegativeRemaining_noRemainingHeader() {
MockHttpServletRequest req = eligibleRequest();
User u = user("dave");
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("METERED_SUBSCRIPTION"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(-1);
invoke(ex, "BODY");
assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
assertThat(counter()).isEqualTo(1.0d);
assertThat(ex.header("X-Credits-Remaining")).isNull();
assertThat(ex.header("X-Credit-Source")).isEqualTo("METERED_SUBSCRIPTION");
}
@Test
@DisplayName("success with null source: charged, remaining header set, no source header")
void successNullSource_noSourceHeader() {
MockHttpServletRequest req = eligibleRequest();
User u = user("erin");
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
// success=true but source=null (defensively handled by the advice)
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(new CreditConsumptionResult(true, null, "ok"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(7);
invoke(ex, "BODY");
assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
assertThat(ex.header("X-Credits-Remaining")).isEqualTo("7");
assertThat(ex.header("X-Credit-Source")).isNull();
}
@Test
@DisplayName("user with personal team is treated as no team -> waterfall, not team pool")
void personalTeam_usesWaterfall() {
MockHttpServletRequest req = eligibleRequest();
User u = user("frank");
u.setTeam(team(99L));
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(true);
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(5);
invoke(ex, "BODY");
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt());
}
}
// --- limited-API users always use personal credits ------------------------------------------
@Nested
@DisplayName("limited-API users always use personal credits (never team pool)")
class LimitedApiUsers {
@Test
@DisplayName("ROLE_LIMITED_API_USER in a non-personal team still uses the waterfall")
void limitedApiUser_usesWaterfall() {
MockHttpServletRequest req = eligibleRequest();
User u = user("lim");
u.setTeam(team(77L));
authenticate(u, "ROLE_LIMITED_API_USER");
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(2);
invoke(ex, "BODY");
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt());
// isPersonal short-circuited by the limited-API check; team service untouched
verify(saasTeamExtensionService, never()).isPersonal(any());
}
@Test
@DisplayName("ROLE_EXTRA_LIMITED_API_USER in a non-personal team still uses the waterfall")
void extraLimitedApiUser_usesWaterfall() {
MockHttpServletRequest req = eligibleRequest();
User u = user("xlim");
u.setTeam(team(88L));
authenticate(u, "ROLE_EXTRA_LIMITED_API_USER");
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(1);
invoke(ex, "BODY");
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt());
}
}
// --- team consumption -----------------------------------------------------------------------
@Nested
@DisplayName("team credit consumption (non-personal team)")
class TeamConsumption {
@Test
@DisplayName("non-personal team success: consumes from team pool, sets source header")
void teamSuccess_consumesTeamPool() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(2));
User u = user("gina");
u.setTeam(team(77L));
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(teamCreditService.consumeCreditWithWaterfall(77L, 2))
.thenReturn(CreditConsumptionResult.success("TEAM_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(100);
Object out = invoke(ex, "BODY");
assertThat(out).isEqualTo("BODY");
assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
assertThat(counter()).isEqualTo(1.0d);
assertThat(ex.header("X-Credit-Source")).isEqualTo("TEAM_CREDITS");
assertThat(ex.header("X-Credits-Remaining")).isEqualTo("100");
verify(teamCreditService).consumeCreditWithWaterfall(77L, 2);
verify(creditService, never())
.consumeCreditWithWaterfall(any(), anyInt(), anyBoolean());
}
@Test
@DisplayName("non-personal team success via leader overage source is propagated")
void teamSuccess_overageSourcePropagated() {
MockHttpServletRequest req = eligibleRequest();
User u = user("greg");
u.setTeam(team(12L));
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(teamCreditService.consumeCreditWithWaterfall(12L, 1))
.thenReturn(CreditConsumptionResult.success("METERED_SUBSCRIPTION"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(0);
invoke(ex, "BODY");
assertThat(ex.header("X-Credit-Source")).isEqualTo("METERED_SUBSCRIPTION");
}
@Test
@DisplayName("non-personal team failure: not charged, counter zero, no headers")
void teamFailure_notCharged() {
MockHttpServletRequest req = eligibleRequest();
User u = user("hank");
u.setTeam(team(55L));
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(teamCreditService.consumeCreditWithWaterfall(55L, 1))
.thenReturn(
CreditConsumptionResult.failure("TEAM_CREDITS_EXHAUSTED_NO_OVERAGE"));
invoke(ex, "BODY");
assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
assertThat(counter()).isZero();
assertThat(ex.header("X-Credits-Remaining")).isNull();
assertThat(ex.header("X-Credit-Source")).isNull();
verify(creditService, never())
.consumeCreditWithWaterfall(any(), anyInt(), anyBoolean());
verifyNoInteractions(creditHeaderUtils);
}
@Test
@DisplayName("team with null id is treated as no team -> waterfall used")
void teamNullId_usesWaterfall() {
MockHttpServletRequest req = eligibleRequest();
User u = user("nina");
u.setTeam(team(null)); // non-personal (isPersonal false) but id null
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(4);
invoke(ex, "BODY");
// targetTeamId resolves to null (team id null) -> individual waterfall
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt());
}
}
// --- user resolution edge cases -------------------------------------------------------------
@Nested
@DisplayName("user resolution edge cases")
class UserResolution {
@Test
@DisplayName("no authentication: getCurrentUser throws, user null -> no consumption")
void noAuth_userNull_noConsumption() {
MockHttpServletRequest req = eligibleRequest();
// no SecurityContext authentication -> AuthenticationUtils throws SecurityException
Object out = invoke(req, "BODY");
assertThat(out).isEqualTo("BODY");
assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
assertThat(counter()).isZero();
verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
}
}
// --- counter accumulation -------------------------------------------------------------------
@Test
@DisplayName("counter accumulates across multiple successful charges")
void counterAccumulates() {
User u = user("mike");
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(3);
// fresh request each time so ATTR_CHARGED from a prior call doesn't block the next
authenticate(u);
invoke(eligibleRequest(), "A");
invoke(eligibleRequest(), "B");
assertThat(counter()).isEqualTo(2.0d);
verify(creditService, times(2)).consumeCreditWithWaterfall(u, 1, false);
}
@Test
@DisplayName("body is always returned verbatim (including null) regardless of charging")
void bodyReturnedVerbatim() {
User u = user("nora");
authenticate(u);
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(1);
Object out = invoke(eligibleRequest(), null);
assertThat(out).isNull();
}
}
@@ -1,806 +0,0 @@
package stirling.software.saas.interceptor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
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.Mockito;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.method.HandlerMethod;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.model.TeamCredit;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.model.UserCredit;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.ErrorTrackingService;
import stirling.software.saas.service.SaasTeamExtensionService;
import stirling.software.saas.service.SaasUserExtensionService;
import stirling.software.saas.service.TeamCreditService;
/**
* Pure-Mockito unit tests for {@link UnifiedCreditInterceptor}. No Spring context: request/response
* are Spring's mock servlet objects, the SecurityContext is populated with real token types
* (matching the sibling {@code PaygChargeInterceptorTest} house style) and every collaborator is a
* Mockito mock. A real {@link SimpleMeterRegistry} backs the counters/timer so increments can be
* asserted directly.
*/
class UnifiedCreditInterceptorTest {
private CreditService creditService;
private ErrorTrackingService errorTrackingService;
private CreditsProperties creditsProperties;
private UserRepository userRepository;
private TeamCreditService teamCreditService;
private TeamMembershipRepository membershipRepository;
private SaasUserExtensionService saasUserExtensionService;
private SaasTeamExtensionService saasTeamExtensionService;
private MeterRegistry meterRegistry;
private UnifiedCreditInterceptor interceptor;
@BeforeEach
void setUp() {
creditService = Mockito.mock(CreditService.class);
errorTrackingService = Mockito.mock(ErrorTrackingService.class);
creditsProperties = new CreditsProperties();
userRepository = Mockito.mock(UserRepository.class);
teamCreditService = Mockito.mock(TeamCreditService.class);
membershipRepository = Mockito.mock(TeamMembershipRepository.class);
saasUserExtensionService = Mockito.mock(SaasUserExtensionService.class);
saasTeamExtensionService = Mockito.mock(SaasTeamExtensionService.class);
meterRegistry = new SimpleMeterRegistry();
interceptor =
new UnifiedCreditInterceptor(
creditService,
errorTrackingService,
creditsProperties,
userRepository,
teamCreditService,
membershipRepository,
saasUserExtensionService,
saasTeamExtensionService,
meterRegistry);
SecurityContextHolder.clearContext();
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
// --- gate short-circuits ---------------------------------------------------------------------
@Nested
@DisplayName("preHandle gate / short-circuit branches")
class GateBranches {
@Test
@DisplayName("credits disabled allows request without touching any collaborator")
void creditsDisabled_allowsAndSkipsValidation() throws Exception {
creditsProperties.setEnabled(false);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
verifyNoInteractions(creditService, teamCreditService, userRepository);
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isNull();
}
@Test
@DisplayName("non-HandlerMethod handler is out of scope and allowed")
void plainHandlerObject_isAllowed() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, new Object());
assertThat(cont).isTrue();
verifyNoInteractions(creditService, teamCreditService);
}
@Test
@DisplayName("HandlerMethod without @AutoJobPostMapping is out of scope and allowed")
void handlerWithoutAnnotation_isAllowed() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForPlain());
assertThat(cont).isTrue();
verifyNoInteractions(creditService, teamCreditService);
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isNull();
}
}
// --- authentication gating -------------------------------------------------------------------
@Nested
@DisplayName("authentication blocking")
class AuthBlocking {
@Test
@DisplayName("null authentication is blocked with 401")
void nullAuth_blockedWith401() throws Exception {
SecurityContextHolder.clearContext();
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(401);
assertThat(res.getContentType()).isEqualTo("application/json");
assertThat(res.getContentAsString()).contains("AUTHENTICATION_REQUIRED");
verifyNoInteractions(creditService, teamCreditService);
}
@Test
@DisplayName("unauthenticated token (isAuthenticated=false) is blocked with 401")
void unauthenticatedToken_blockedWith401() throws Exception {
// 2-arg ctor leaves isAuthenticated()=false, so it falls through to the security block.
SecurityContextHolder.getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken("someone", "creds"));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(401);
assertThat(res.getContentAsString()).contains("AUTHENTICATION_REQUIRED");
}
}
// --- JWT lookup / role bypass ----------------------------------------------------------------
@Nested
@DisplayName("JWT authentication: lookup, role bypass and bad identifiers")
class JwtLookupAndBypass {
@Test
@DisplayName("JWT user not found in repository returns 500 USER_NOT_FOUND")
void jwtUserMissing_returns500() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.empty());
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(500);
assertThat(res.getContentAsString()).contains("USER_NOT_FOUND");
verify(creditService, never()).getOrCreateUserCredits(any());
}
@Test
@DisplayName("JWT with non-UUID identifier returns 400 INVALID_USER_ID")
void jwtInvalidSupabaseIdFormat_returns400() throws Exception {
// auth.getName() is not a UUID → UUID.fromString throws IllegalArgumentException.
authenticateJwt("not-a-uuid", "ROLE_USER");
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(400);
assertThat(res.getContentAsString()).contains("INVALID_USER_ID");
verify(creditService, never()).getOrCreateUserCredits(any());
}
@Test
@DisplayName("admin JWT user bypasses credit validation and bumps jwt_bypass counter")
void adminJwtUser_bypassesValidation() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_ADMIN");
User admin = makeUser(1L, null, "ROLE_ADMIN");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(admin));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isNull();
assertThat(meterRegistry.counter("credits.validation.jwt_bypass").count())
.isEqualTo(1.0);
verify(creditService, never()).getOrCreateUserCredits(any());
}
@Test
@DisplayName("internal backend-API JWT user bypasses credit validation")
void internalBackendApiUser_bypassesValidation() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "STIRLING-PDF-BACKEND-API-USER");
User internal = makeUser(2L, null, "STIRLING-PDF-BACKEND-API-USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(internal));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
assertThat(meterRegistry.counter("credits.validation.jwt_bypass").count())
.isEqualTo(1.0);
verify(creditService, never()).getOrCreateUserCredits(any());
}
@Test
@DisplayName("pro JWT user is NOT bypassed - still flows into waterfall credit checks")
void proJwtUser_isSubjectToCreditChecks() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_PRO_USER");
User pro = makeUser(3L, null, "ROLE_PRO_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(pro));
when(creditService.getOrCreateUserCredits(pro)).thenReturn(userCredit(100));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
// Pro is not bypassed; it consumed the credit-check path so the eligible flag is set.
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
assertThat(meterRegistry.counter("credits.validation.jwt_bypass").count())
.isEqualTo(0.0);
verify(creditService).getOrCreateUserCredits(pro);
}
}
// --- JWT personal-credit path ----------------------------------------------------------------
@Nested
@DisplayName("JWT personal-credit path")
class JwtPersonalCredits {
@Test
@DisplayName("sufficient personal credits passes and marks request eligible")
void sufficientPersonalCredits_passes() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
User user = makeUser(10L, null, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(50));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
// JWT credit identifier is the Supabase id, weight clamps to 1 (default sentinel).
assertThat(req.getAttribute("CREDIT_API_KEY")).isEqualTo(supabaseId);
assertThat(req.getAttribute("CREDIT_RESOURCE_WEIGHT")).isEqualTo(1);
assertThat(req.getAttribute("IS_API_KEY_AUTH")).isEqualTo(false);
assertThat(req.getAttribute("IS_API_REQUEST")).isEqualTo(false);
assertThat(meterRegistry.counter("credits.validation.checked").count()).isEqualTo(1.0);
verify(teamCreditService, never()).getTeamCredits(any());
}
@Test
@DisplayName("insufficient personal credits without metered billing is rejected with 429")
void insufficientPersonalCredits_rejectedWith429() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
User user = makeUser(11L, null, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(0));
when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(429);
assertThat(res.getContentAsString()).contains("INSUFFICIENT_CREDITS");
// Personal (not team) message wording.
assertThat(res.getContentAsString()).contains("Insufficient API credits");
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isNull();
assertThat(meterRegistry.counter("credits.validation.rejected").count()).isEqualTo(1.0);
}
@Test
@DisplayName(
"insufficient personal credits but metered billing enabled proceeds on overage")
void insufficientPersonalCredits_withMeteredBilling_proceeds() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
User user = makeUser(12L, null, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(0));
when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(true);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
assertThat(meterRegistry.counter("credits.validation.rejected").count()).isEqualTo(0.0);
}
}
// --- JWT team-credit path --------------------------------------------------------------------
@Nested
@DisplayName("JWT team-credit path")
class JwtTeamCredits {
@Test
@DisplayName("non-personal team with sufficient team credits passes")
void teamCreditsSufficient_passes() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
Team team = makeTeam(500L);
User user = makeUser(20L, team, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
when(teamCreditService.getTeamCredits(500L)).thenReturn(Optional.of(teamCredit(80)));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
verify(teamCreditService).getTeamCredits(500L);
verify(creditService, never()).getOrCreateUserCredits(any());
}
@Test
@DisplayName("non-personal team with no credits but leader metered billing proceeds")
void teamCreditsExhausted_leaderMetered_proceeds() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
Team team = makeTeam(501L);
User user = makeUser(21L, team, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
when(teamCreditService.getTeamCredits(501L)).thenReturn(Optional.of(teamCredit(0)));
// Leader has metered billing → overage allowed even with zero team credits.
User leader = makeUser(99L, team, "ROLE_USER");
TeamMembership leaderMembership = new TeamMembership();
leaderMembership.setUser(leader);
leaderMembership.setRole(TeamRole.LEADER);
when(membershipRepository.findByTeamIdAndRole(501L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership));
when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
}
@Test
@DisplayName(
"non-personal team with no credits and no leader metered is rejected (team msg)")
void teamCreditsExhausted_noLeaderMetered_rejectedWithTeamMessage() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
Team team = makeTeam(502L);
User user = makeUser(22L, team, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
when(teamCreditService.getTeamCredits(502L)).thenReturn(Optional.of(teamCredit(0)));
when(membershipRepository.findByTeamIdAndRole(502L, TeamRole.LEADER))
.thenReturn(List.of());
when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(429);
assertThat(res.getContentAsString()).contains("Insufficient team credits");
assertThat(meterRegistry.counter("credits.validation.rejected").count()).isEqualTo(1.0);
}
@Test
@DisplayName("personal team falls back to personal credits, not team credits")
void personalTeam_usesPersonalCredits() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
Team team = makeTeam(503L);
User user = makeUser(23L, team, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(saasTeamExtensionService.isPersonal(team)).thenReturn(true);
when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(10));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
verify(creditService).getOrCreateUserCredits(user);
verify(teamCreditService, never()).getTeamCredits(any());
}
@Test
@DisplayName("limited-API JWT user in a team uses personal credits, never team credits")
void limitedApiUserInTeam_usesPersonalCredits() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_LIMITED_API_USER");
Team team = makeTeam(504L);
User user = makeUser(24L, team, "ROLE_LIMITED_API_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(5));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
verify(creditService).getOrCreateUserCredits(user);
verify(teamCreditService, never()).getTeamCredits(any());
verify(saasTeamExtensionService, never()).isPersonal(any());
}
@Test
@DisplayName("leader-metered lookup that throws is swallowed and treated as not-metered")
void leaderMeteredLookupThrows_treatedAsNotMetered() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
Team team = makeTeam(505L);
User user = makeUser(25L, team, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
when(teamCreditService.getTeamCredits(505L)).thenReturn(Optional.of(teamCredit(0)));
when(membershipRepository.findByTeamIdAndRole(505L, TeamRole.LEADER))
.thenThrow(new RuntimeException("db down"));
when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
// Exception in checkTeamLeaderMeteredBilling is caught → false → rejected, not a 500.
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(429);
}
}
// --- API key path ----------------------------------------------------------------------------
@Nested
@DisplayName("API key authentication path")
class ApiKeyPath {
@Test
@DisplayName("API key with sufficient credits passes and flags API request attributes")
void apiKeySufficientCredits_passes() throws Exception {
User user = makeUser(30L, null, "ROLE_API");
user.setApiKey("sk-abcd1234efgh5678");
authenticateApiKey(user, "sk-abcd1234efgh5678");
when(creditService.getUserCreditsByApiKey("sk-abcd1234efgh5678"))
.thenReturn(Optional.of(userCredit(20)));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
assertThat(req.getAttribute("CREDIT_API_KEY")).isEqualTo("sk-abcd1234efgh5678");
assertThat(req.getAttribute("IS_API_KEY_AUTH")).isEqualTo(true);
assertThat(req.getAttribute("IS_API_REQUEST")).isEqualTo(true);
verify(creditService).getUserCreditsByApiKey("sk-abcd1234efgh5678");
verify(creditService, never()).getOrCreateUserCredits(any());
}
@Test
@DisplayName("API key with no credit row defaults to zero balance and is rejected")
void apiKeyNoCreditRow_rejectedWith429() throws Exception {
User user = makeUser(31L, null, "ROLE_API");
user.setApiKey("sk-zzzz0000zzzz0000");
authenticateApiKey(user, "sk-zzzz0000zzzz0000");
when(creditService.getUserCreditsByApiKey("sk-zzzz0000zzzz0000"))
.thenReturn(Optional.empty());
when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(429);
// API key users always use personal credits → personal message even with a team absent.
assertThat(res.getContentAsString()).contains("Insufficient API credits");
}
@Test
@DisplayName("API key insufficient credits but metered billing enabled proceeds")
void apiKeyInsufficientCredits_withMeteredBilling_proceeds() throws Exception {
User user = makeUser(32L, null, "ROLE_API");
user.setApiKey("sk-meter0000meter0");
authenticateApiKey(user, "sk-meter0000meter0");
when(creditService.getUserCreditsByApiKey("sk-meter0000meter0"))
.thenReturn(Optional.of(userCredit(0)));
when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(true);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
}
}
// --- resource weight clamping ----------------------------------------------------------------
@Nested
@DisplayName("resource weight clamping")
class ResourceWeightClamping {
@Test
@DisplayName("weight above 100 clamps to 100")
void weightAbove100_clampsTo100() throws Exception {
User user = makeUser(40L, null, "ROLE_API");
user.setApiKey("sk-clamp0000clamp00");
authenticateApiKey(user, "sk-clamp0000clamp00");
when(creditService.getUserCreditsByApiKey("sk-clamp0000clamp00"))
.thenReturn(Optional.of(userCredit(1000)));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForHeavy());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_RESOURCE_WEIGHT")).isEqualTo(100);
}
@Test
@DisplayName("a weight-100 job is rejected when balance is below 100 (no rounding leak)")
void weight100_balance50_rejected() throws Exception {
User user = makeUser(41L, null, "ROLE_API");
user.setApiKey("sk-tight0000tight00");
authenticateApiKey(user, "sk-tight0000tight00");
when(creditService.getUserCreditsByApiKey("sk-tight0000tight00"))
.thenReturn(Optional.of(userCredit(50)));
when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForHeavy());
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(429);
}
@Test
@DisplayName("exact balance == weight is sufficient (>= boundary)")
void exactBalanceEqualsWeight_passes() throws Exception {
User user = makeUser(42L, null, "ROLE_API");
user.setApiKey("sk-exact0000exact00");
authenticateApiKey(user, "sk-exact0000exact00");
// Heavy endpoint weight clamps to 100; exactly 100 credits is sufficient.
when(creditService.getUserCreditsByApiKey("sk-exact0000exact00"))
.thenReturn(Optional.of(userCredit(100)));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForHeavy());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
}
}
// --- lifecycle no-ops ------------------------------------------------------------------------
@Nested
@DisplayName("postHandle / afterCompletion / afterConcurrentHandlingStarted are no-ops")
class LifecycleNoOps {
@Test
@DisplayName("postHandle does no spending and touches no collaborator")
void postHandle_isNoOp() throws Exception {
interceptor.postHandle(
new MockHttpServletRequest(),
new MockHttpServletResponse(),
handlerMethodForAuto(),
null);
verifyNoInteractions(
creditService, teamCreditService, errorTrackingService, userRepository);
}
@Test
@DisplayName("afterCompletion with no exception does no spending")
void afterCompletion_success_isNoOp() throws Exception {
interceptor.afterCompletion(
new MockHttpServletRequest(),
new MockHttpServletResponse(),
handlerMethodForAuto(),
null);
verifyNoInteractions(creditService, teamCreditService, errorTrackingService);
}
@Test
@DisplayName("afterCompletion with an exception still does no spending")
void afterCompletion_withException_isNoOp() throws Exception {
interceptor.afterCompletion(
new MockHttpServletRequest(),
new MockHttpServletResponse(),
handlerMethodForAuto(),
new RuntimeException("boom"));
verifyNoInteractions(creditService, teamCreditService, errorTrackingService);
}
@Test
@DisplayName("afterConcurrentHandlingStarted does no spending")
void afterConcurrentHandlingStarted_isNoOp() throws Exception {
interceptor.afterConcurrentHandlingStarted(
new MockHttpServletRequest(),
new MockHttpServletResponse(),
handlerMethodForAuto());
verifyNoInteractions(creditService, teamCreditService, errorTrackingService);
}
}
// --- helpers ---------------------------------------------------------------------------------
private void authenticateJwt(String name, String role) {
// Not an EnhancedJwtAuthenticationToken, so extractSupabaseId falls back to auth.getName().
// 3-arg ctor → isAuthenticated()=true so the JWT branch is taken.
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(
name, null, List.of(new SimpleGrantedAuthority(role)));
SecurityContextHolder.getContext().setAuthentication(token);
}
private void authenticateApiKey(User user, String apiKey) {
ApiKeyAuthenticationToken token =
new ApiKeyAuthenticationToken(
user, apiKey, List.of(new SimpleGrantedAuthority("ROLE_API")));
SecurityContextHolder.getContext().setAuthentication(token);
}
private static UserCredit userCredit(int cycleCredits) {
UserCredit c = new UserCredit();
c.setCycleCreditsRemaining(cycleCredits);
c.setBoughtCreditsRemaining(0);
return c;
}
private static TeamCredit teamCredit(int cycleCredits) {
TeamCredit c = new TeamCredit();
c.setCycleCreditsRemaining(cycleCredits);
c.setBoughtCreditsRemaining(0);
return c;
}
private static Team makeTeam(Long id) {
Team team = new Team();
team.setId(id);
return team;
}
private static User makeUser(Long id, Team team, String role) {
User user = new User();
try {
java.lang.reflect.Field idField = User.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(user, id);
} catch (ReflectiveOperationException e) {
throw new RuntimeException(e);
}
user.setUsername("user-" + id);
user.setSupabaseId(UUID.randomUUID());
if (team != null) {
user.setTeam(team);
}
if (role != null) {
// Authority ctor self-registers into user.getAuthorities().
new Authority(role, user);
}
return user;
}
private static HandlerMethod handlerMethodForAuto() {
return handlerMethod("handleAuto");
}
private static HandlerMethod handlerMethodForHeavy() {
return handlerMethod("handleHeavy");
}
private static HandlerMethod handlerMethodForPlain() {
return handlerMethod("handlePlain");
}
private static HandlerMethod handlerMethod(String name) {
try {
Method m = FakeController.class.getDeclaredMethod(name);
return new HandlerMethod(new FakeController(), m);
} catch (NoSuchMethodException e) {
throw new RuntimeException(e);
}
}
static class FakeController {
// No explicit resourceWeight → default sentinel Integer.MIN_VALUE → clamps to 1.
@AutoJobPostMapping(value = "/auto")
public void handleAuto() {}
// Above-max weight to exercise the clamp-to-100 branch.
@AutoJobPostMapping(value = "/heavy", resourceWeight = 5000)
public void handleHeavy() {}
public void handlePlain() {}
}
}
@@ -174,7 +174,7 @@ class JobChargeServiceTest {
assertThat(row.getJobId()).isEqualTo(newJob.getId());
assertThat(row.getPolicyId()).isEqualTo(policy.getId());
assertThat(row.getPaygUnits()).isEqualTo(4);
// Legacy comparison not wired yet — zeroed until CreditService is wired in the follow-up.
// Legacy comparison removed with the legacy credit engine — always zeroed.
assertThat(row.getLegacyCreditsCharged()).isZero();
assertThat(row.getDiffPct()).isZero();
// PAYG analytics axis: billing_category + job_source are copied from the context so the
@@ -26,9 +26,8 @@ import stirling.software.saas.payg.policy.admin.PolicyDtos.PolicyResponse;
import stirling.software.saas.payg.policy.admin.PolicyDtos.TeamOverrideRequest;
/**
* Tests {@link PricingPolicyAdminController} as a plain Java unit (matching {@code
* CreditControllerApiKeyTest}'s style — no MockMvc layer). Covers happy paths and the controller's
* error mapping (4xx for validation, 404 for missing rows).
* Tests {@link PricingPolicyAdminController} as a plain Java unit (no MockMvc layer). Covers happy
* paths and the controller's error mapping (4xx for validation, 404 for missing rows).
*/
@ExtendWith(MockitoExtension.class)
class PricingPolicyAdminControllerTest {
@@ -30,7 +30,6 @@ import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
@@ -46,7 +45,6 @@ class SupabaseAuthenticationFilterTest {
@Mock private TeamService teamService;
@Mock private UserService userService;
@Mock private SupabaseUserService supabaseUserService;
@Mock private stirling.software.saas.service.CreditService creditService;
@Mock private stirling.software.saas.service.SaasTeamService saasTeamService;
@Mock private JwtDecoder jwtDecoder;
@@ -60,12 +58,7 @@ class SupabaseAuthenticationFilterTest {
SecurityContextHolder.clearContext();
filter =
new SupabaseAuthenticationFilter(
teamService,
userService,
supabaseUserService,
creditService,
saasTeamService,
jwtDecoder);
teamService, userService, supabaseUserService, saasTeamService, jwtDecoder);
request = new MockHttpServletRequest();
response = new MockHttpServletResponse();
chain = new MockFilterChain();
@@ -161,7 +154,6 @@ class SupabaseAuthenticationFilterTest {
when(supabaseUserService.getUser(supabaseId))
.thenReturn(supabaseUserMatching(supabaseId, "bob@example.com", false));
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
when(teamService.getOrCreateDefaultTeam()).thenReturn(new Team());
when(userService.saveUser(any())).thenAnswer(inv -> inv.getArgument(0));
request.setRequestURI("/api/v1/something");
@@ -172,6 +164,9 @@ class SupabaseAuthenticationFilterTest {
verify(userService, times(1)).saveUser(any(User.class));
verify(supabaseUserService).createSupabaseUser(supabaseId, "bob@example.com", false);
// New users get their own personal team, never the shared Default team.
verify(saasTeamService).ensurePersonalTeam(any(User.class));
verify(teamService, never()).getOrCreateDefaultTeam();
assertThat(SecurityContextHolder.getContext().getAuthentication())
.isInstanceOf(EnhancedJwtAuthenticationToken.class);
}
@@ -185,7 +180,6 @@ class SupabaseAuthenticationFilterTest {
when(supabaseUserService.getUser(supabaseId))
.thenReturn(supabaseUserMatching(supabaseId, "carol@example.com", false));
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
when(teamService.getOrCreateDefaultTeam()).thenReturn(new Team());
when(userService.saveUser(any(User.class)))
.thenAnswer(
inv -> {
@@ -214,7 +208,6 @@ class SupabaseAuthenticationFilterTest {
when(supabaseUserService.getUser(supabaseId))
.thenReturn(supabaseUserMatching(supabaseId, "dave@example.com", false));
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
when(teamService.getOrCreateDefaultTeam()).thenReturn(new Team());
when(userService.saveUser(any(User.class)))
.thenAnswer(
inv -> {
@@ -243,7 +236,6 @@ class SupabaseAuthenticationFilterTest {
when(supabaseUserService.getUser(supabaseId))
.thenReturn(supabaseUserMatching(supabaseId, "eve@example.com", false));
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
when(teamService.getOrCreateDefaultTeam()).thenReturn(new Team());
when(userService.saveUser(any(User.class)))
.thenAnswer(
inv -> {
@@ -1,219 +0,0 @@
package stirling.software.saas.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
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.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.saas.config.CreditsProperties;
/**
* Unit tests for {@link CreditResetScheduler}.
*
* <p>The scheduler has two {@code @Scheduled} entry points that are invoked directly (no Spring
* context, no real cron firing). {@code resetCycleCredits()} parses the configured zone, builds a
* {@code LocalDateTime} in that zone, and delegates to {@link CreditService} for users then teams,
* swallowing any exception. {@code performDailyMaintenance()} is a pure no-op log step that must
* never touch {@link CreditService}.
*/
@ExtendWith(MockitoExtension.class)
class CreditResetSchedulerTest {
@Mock private CreditService creditService;
/** Real CreditsProperties (a simple @Data POJO) configured per test via its nested Reset. */
private static CreditsProperties props(String cron, String zone) {
CreditsProperties p = new CreditsProperties();
p.getReset().setCron(cron);
p.getReset().setZone(zone);
return p;
}
private CreditResetScheduler scheduler(CreditsProperties props) {
return new CreditResetScheduler(creditService, props);
}
@Nested
@DisplayName("resetCycleCredits - happy path")
class ResetHappyPath {
@Test
@DisplayName("resets users then teams exactly once each")
void resetsUsersThenTeamsOnce() {
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
s.resetCycleCredits();
verify(creditService, times(1)).resetCycleCreditsForAllUsers(any(LocalDateTime.class));
verify(creditService, times(1)).resetCycleCreditsForAllTeams(any(LocalDateTime.class));
}
@Test
@DisplayName("users are reset strictly before teams")
void usersResetBeforeTeams() {
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
s.resetCycleCredits();
InOrder order = inOrder(creditService);
order.verify(creditService).resetCycleCreditsForAllUsers(any(LocalDateTime.class));
order.verify(creditService).resetCycleCreditsForAllTeams(any(LocalDateTime.class));
order.verifyNoMoreInteractions();
}
@Test
@DisplayName("passes a non-null reset time and uses the same instant for users and teams")
void passesSameNonNullResetTimeToBoth() {
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
s.resetCycleCredits();
ArgumentCaptor<LocalDateTime> userTime = ArgumentCaptor.forClass(LocalDateTime.class);
ArgumentCaptor<LocalDateTime> teamTime = ArgumentCaptor.forClass(LocalDateTime.class);
verify(creditService).resetCycleCreditsForAllUsers(userTime.capture());
verify(creditService).resetCycleCreditsForAllTeams(teamTime.capture());
assertThat(userTime.getValue()).isNotNull();
assertThat(teamTime.getValue()).isNotNull();
// The very same LocalDateTime instance is threaded through both calls.
assertThat(teamTime.getValue()).isSameAs(userTime.getValue());
}
@Test
@DisplayName("reset time is computed in the configured zone (~now)")
void resetTimeMatchesConfiguredZone() {
String zone = "UTC";
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", zone));
LocalDateTime expected = LocalDateTime.now(ZoneId.of(zone));
s.resetCycleCredits();
ArgumentCaptor<LocalDateTime> captor = ArgumentCaptor.forClass(LocalDateTime.class);
verify(creditService).resetCycleCreditsForAllUsers(captor.capture());
// Wall-clock now in the same zone; allow generous slack to avoid flakiness.
assertThat(captor.getValue()).isCloseTo(expected, within(10, ChronoUnit.SECONDS));
}
@Test
@DisplayName("a non-UTC zone is honored when building the reset time")
void nonUtcZoneHonored() {
String zone = "America/New_York";
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", zone));
LocalDateTime expected = LocalDateTime.now(ZoneId.of(zone));
s.resetCycleCredits();
ArgumentCaptor<LocalDateTime> captor = ArgumentCaptor.forClass(LocalDateTime.class);
verify(creditService).resetCycleCreditsForAllUsers(captor.capture());
assertThat(captor.getValue()).isCloseTo(expected, within(10, ChronoUnit.SECONDS));
}
}
@Nested
@DisplayName("resetCycleCredits - error handling (exceptions are swallowed)")
class ResetErrorHandling {
@Test
@DisplayName("user-reset failure is swallowed and short-circuits the team reset")
void userResetThrows_swallowedAndTeamsSkipped() {
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
doThrow(new RuntimeException("user reset boom"))
.when(creditService)
.resetCycleCreditsForAllUsers(any(LocalDateTime.class));
// Must not propagate.
s.resetCycleCredits();
verify(creditService).resetCycleCreditsForAllUsers(any(LocalDateTime.class));
// Exception thrown before the team call is reached.
verify(creditService, never()).resetCycleCreditsForAllTeams(any(LocalDateTime.class));
}
@Test
@DisplayName("team-reset failure is swallowed after users already reset")
void teamResetThrows_swallowedAfterUsersReset() {
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
doThrow(new RuntimeException("team reset boom"))
.when(creditService)
.resetCycleCreditsForAllTeams(any(LocalDateTime.class));
// Must not propagate.
s.resetCycleCredits();
verify(creditService).resetCycleCreditsForAllUsers(any(LocalDateTime.class));
verify(creditService).resetCycleCreditsForAllTeams(any(LocalDateTime.class));
}
@Test
@DisplayName("an invalid zone string is swallowed and no reset work is attempted")
void invalidZone_swallowedNoResets() {
// ZoneId.of on a bad id throws DateTimeException before any delegation occurs.
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "Not/AZone"));
// Must not propagate.
s.resetCycleCredits();
verifyNoInteractions(creditService);
}
@Test
@DisplayName("any RuntimeException subtype from the user reset is swallowed")
void userResetRuntimeExceptionTolerated() {
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
// Production catches (Exception e); any RuntimeException is absorbed.
doThrow(new IllegalStateException("transient"))
.when(creditService)
.resetCycleCreditsForAllUsers(any(LocalDateTime.class));
s.resetCycleCredits();
verify(creditService).resetCycleCreditsForAllUsers(any(LocalDateTime.class));
verify(creditService, never()).resetCycleCreditsForAllTeams(any(LocalDateTime.class));
}
}
@Nested
@DisplayName("performDailyMaintenance")
class DailyMaintenance {
@Test
@DisplayName("runs cleanly and never touches the credit service")
void noOp_doesNotTouchCreditService() {
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
s.performDailyMaintenance();
verifyNoInteractions(creditService);
}
@Test
@DisplayName("is idempotent across repeated invocations")
void repeatedInvocationsRemainNoOp() {
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
s.performDailyMaintenance();
s.performDailyMaintenance();
s.performDailyMaintenance();
verifyNoInteractions(creditService);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,586 +0,0 @@
package stirling.software.saas.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.Optional;
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 stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.model.UserErrorTracker;
import stirling.software.saas.repository.UserErrorTrackerRepository;
import stirling.software.saas.service.ErrorTrackingService.ErrorInfo;
/**
* Unit tests for {@link ErrorTrackingService}.
*
* <p>The service has two distinct tracking paths chosen at construction time based on {@link
* CreditsProperties.Cache#isLocalEnabled()}: an in-memory Caffeine cache path and a direct-to-DB
* fallback. Because the cache field is final and decided in the constructor, each path is exercised
* by building the service with a tailored {@link CreditsProperties}. Defaults are {@code
* freeProcessingErrors = 2} and {@code ttlMinutes = 60}.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ErrorTrackingServiceTest {
@Mock private UserErrorTrackerRepository errorTrackerRepository;
@Mock private UserRepository userRepository;
private static final String API_KEY =
"test-api-key-0001"; // gitleaks:allow - test fixture, not a secret
private static final String ENDPOINT = "/api/v1/convert/pdf-to-img";
/** Build a CreditsProperties with the given cache + error config. */
private static CreditsProperties props(
boolean localCacheEnabled, int freeProcessingErrors, int ttlMinutes) {
CreditsProperties p = new CreditsProperties();
p.getCache().setLocalEnabled(localCacheEnabled);
p.getErrors().setFreeProcessingErrors(freeProcessingErrors);
p.getErrors().setTtlMinutes(ttlMinutes);
return p;
}
private ErrorTrackingService cachedService(int freeProcessingErrors) {
return new ErrorTrackingService(
errorTrackerRepository, userRepository, props(true, freeProcessingErrors, 60));
}
private ErrorTrackingService dbService(int freeProcessingErrors) {
return new ErrorTrackingService(
errorTrackerRepository, userRepository, props(false, freeProcessingErrors, 60));
}
private static User user(String username, String apiKey) {
User u = new User();
u.setUsername(username);
u.setApiKey(apiKey);
return u;
}
/**
* A throwable that classifies as PROCESSING_ERROR when paired with httpStatus 200 and a
* non-null endpoint: not a validation/system error, so the endpoint-based processing branch
* wins.
*/
private static Throwable processingThrowable() {
return new RuntimeException("corrupt pdf stream while rendering page");
}
@Nested
@DisplayName("recordErrorAndShouldConsumeCredit - error classification gate")
class ClassificationGate {
@Test
@DisplayName("validation error (400) never charges and never touches DB or cache")
void validationError_doesNotCharge() {
ErrorTrackingService service = cachedService(2);
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY,
ENDPOINT,
new IllegalArgumentException("missing parameter"),
400);
assertThat(charge).isFalse();
verify(errorTrackerRepository, never()).save(any());
verifyNoInteractions(userRepository);
}
@Test
@DisplayName("auth error (401) classifies as validation and never charges")
void authError_doesNotCharge() {
ErrorTrackingService service = cachedService(2);
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, new RuntimeException("denied"), 401);
assertThat(charge).isFalse();
verify(errorTrackerRepository, never()).save(any());
}
@Test
@DisplayName("system error (500) never charges")
void systemError_doesNotCharge() {
ErrorTrackingService service = cachedService(2);
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, new RuntimeException("server boom"), 500);
assertThat(charge).isFalse();
verify(errorTrackerRepository, never()).save(any());
}
@Test
@DisplayName("system exception type (SQLException) at 200 never charges")
void systemExceptionType_doesNotCharge() {
ErrorTrackingService service = cachedService(2);
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, new SQLException("db down"), 200);
assertThat(charge).isFalse();
verify(errorTrackerRepository, never()).save(any());
}
}
@Nested
@DisplayName("recordErrorAndShouldConsumeCredit - cache path (freeProcessingErrors=2)")
class CachePath {
@Test
@DisplayName("first two processing errors are free; the 3rd charges")
void firstTwoFree_thirdCharges() {
ErrorTrackingService service = cachedService(2);
User u = user("alice", API_KEY);
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
.thenReturn(Optional.empty());
// count=1 -> not > 2
assertThat(call(service)).isFalse();
// count=2 -> not > 2
assertThat(call(service)).isFalse();
// count=3 -> > 2 -> charge, and threshold-crossing persists to DB
assertThat(call(service)).isTrue();
// count=4 -> still charges, but no second persist (only on the exact crossing)
assertThat(call(service)).isTrue();
// Persisted exactly once: on the threshold-crossing call (count == free + 1).
verify(errorTrackerRepository, times(1)).save(any(UserErrorTracker.class));
}
@Test
@DisplayName("threshold-crossing persists a tracker set to free+1 with future resetAfter")
void thresholdCrossing_persistsTrackerWithCorrectCount() {
ErrorTrackingService service = cachedService(2);
User u = user("bob", API_KEY);
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
.thenReturn(Optional.empty());
LocalDateTime before = LocalDateTime.now();
call(service); // 1
call(service); // 2
call(service); // 3 -> persist
ArgumentCaptor<UserErrorTracker> captor =
ArgumentCaptor.forClass(UserErrorTracker.class);
verify(errorTrackerRepository).save(captor.capture());
UserErrorTracker saved = captor.getValue();
assertThat(saved.getProcessingErrorCount()).isEqualTo(3); // free(2) + 1
assertThat(saved.getUser()).isSameAs(u);
assertThat(saved.getEndpoint()).isEqualTo(ENDPOINT);
assertThat(saved.getLastProcessingError()).isNotNull();
assertThat(saved.getResetAfter()).isAfter(before);
}
@Test
@DisplayName("zero free errors: first processing error charges immediately")
void zeroFree_firstErrorCharges() {
ErrorTrackingService service = cachedService(0);
User u = user("carol", API_KEY);
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
.thenReturn(Optional.empty());
// count=1 > free(0) -> charge, and 1 == free+1 -> persist
assertThat(call(service)).isTrue();
verify(errorTrackerRepository, times(1)).save(any(UserErrorTracker.class));
}
@Test
@DisplayName("distinct endpoints are tracked independently in the cache")
void distinctEndpoints_trackedSeparately() {
ErrorTrackingService service = cachedService(2);
// Two errors on endpoint A, two on endpoint B -> neither crosses the free=2 threshold.
assertThat(
service.recordErrorAndShouldConsumeCredit(
API_KEY, "/a", processingThrowable(), 200))
.isFalse();
assertThat(
service.recordErrorAndShouldConsumeCredit(
API_KEY, "/a", processingThrowable(), 200))
.isFalse();
assertThat(
service.recordErrorAndShouldConsumeCredit(
API_KEY, "/b", processingThrowable(), 200))
.isFalse();
assertThat(
service.recordErrorAndShouldConsumeCredit(
API_KEY, "/b", processingThrowable(), 200))
.isFalse();
// Neither key crossed free+1, so no DB persistence at all.
verify(errorTrackerRepository, never()).save(any());
}
@Test
@DisplayName("cache path does not blow up if the user is absent at persist time")
void cachePersist_userAbsent_swallowsAndStillCharges() {
ErrorTrackingService service = cachedService(2);
// No user for this key: persistErrorToDatabase finds nothing and saves nothing.
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.empty());
assertThat(call(service)).isFalse(); // 1
assertThat(call(service)).isFalse(); // 2
assertThat(call(service)).isTrue(); // 3 -> tries persist, user missing -> no save
verify(errorTrackerRepository, never()).save(any());
}
/** Convenience: record one processing error on the standard key. */
private boolean call(ErrorTrackingService service) {
return service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, processingThrowable(), 200);
}
}
@Nested
@DisplayName("recordErrorAndShouldConsumeCredit - DB fallback path (cache disabled)")
class DbFallbackPath {
@Test
@DisplayName("unknown API key returns false and saves nothing")
void unknownApiKey_returnsFalse() {
ErrorTrackingService service = dbService(2);
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.empty());
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, processingThrowable(), 200);
assertThat(charge).isFalse();
verify(errorTrackerRepository, never()).save(any());
}
@Test
@DisplayName(
"creates a new tracker, records the error and persists; below threshold no charge")
void newTracker_belowThreshold_noCharge() {
ErrorTrackingService service = dbService(2);
User u = user("dave", API_KEY);
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
.thenReturn(Optional.empty());
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, processingThrowable(), 200);
assertThat(charge).isFalse(); // count 1, free 2
ArgumentCaptor<UserErrorTracker> captor =
ArgumentCaptor.forClass(UserErrorTracker.class);
verify(errorTrackerRepository).save(captor.capture());
assertThat(captor.getValue().getProcessingErrorCount()).isEqualTo(1);
assertThat(captor.getValue().getUser()).isSameAs(u);
}
@Test
@DisplayName("existing tracker already at the threshold rolls over to charging")
void existingTracker_atThreshold_charges() {
ErrorTrackingService service = dbService(2);
User u = user("erin", API_KEY);
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
UserErrorTracker tracker = new UserErrorTracker(u, ENDPOINT, 60);
tracker.setProcessingErrorCount(2); // at the free limit
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
.thenReturn(Optional.of(tracker));
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, processingThrowable(), 200);
// recordProcessingError bumps 2 -> 3, which is > free(2) -> charge
assertThat(charge).isTrue();
assertThat(tracker.getProcessingErrorCount()).isEqualTo(3);
verify(errorTrackerRepository).save(tracker);
}
@Test
@DisplayName("expired existing tracker is reset before recording the new error")
void expiredTracker_isResetThenRecorded() {
ErrorTrackingService service = dbService(2);
User u = user("finn", API_KEY);
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
UserErrorTracker tracker = new UserErrorTracker(u, ENDPOINT, 60);
tracker.setProcessingErrorCount(5);
tracker.setResetAfter(LocalDateTime.now().minusMinutes(1)); // expired
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
.thenReturn(Optional.of(tracker));
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, processingThrowable(), 200);
// reset to 0, then recordProcessingError -> 1, which is not > free(2)
assertThat(charge).isFalse();
assertThat(tracker.getProcessingErrorCount()).isEqualTo(1);
verify(errorTrackerRepository).save(tracker);
}
}
@Nested
@DisplayName("hasHighErrorCount")
class HasHighErrorCount {
@Test
@DisplayName("returns false when no tracker exists for the key")
void noTracker_false() {
ErrorTrackingService service = cachedService(2);
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
.thenReturn(Optional.empty());
assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isFalse();
}
@Test
@DisplayName("true when the tracker's count exceeds the free allowance")
void aboveFree_true() {
ErrorTrackingService service = cachedService(2);
UserErrorTracker tracker = new UserErrorTracker(user("g", API_KEY), ENDPOINT, 60);
tracker.setProcessingErrorCount(3); // > 2
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
.thenReturn(Optional.of(tracker));
assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isTrue();
}
@Test
@DisplayName("false when the count is exactly at the free allowance (boundary)")
void atFree_false() {
ErrorTrackingService service = cachedService(2);
UserErrorTracker tracker = new UserErrorTracker(user("g", API_KEY), ENDPOINT, 60);
tracker.setProcessingErrorCount(2); // not > 2
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
.thenReturn(Optional.of(tracker));
assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isFalse();
}
}
@Nested
@DisplayName("getErrorInfo")
class GetErrorInfo {
@Test
@DisplayName("cache hit reflects the live cached count, remaining free and charging flag")
void cacheHit_reportsLiveCount() {
ErrorTrackingService service = cachedService(2);
// Drive the cache to 3 errors on the key so a subsequent getErrorInfo reads it.
for (int i = 0; i < 3; i++) {
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, processingThrowable(), 200);
}
ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
assertThat(info.currentErrorCount).isEqualTo(3);
// Math.max(0, free(2) - 3) == 0
assertThat(info.errorsUntilCharged).isZero();
assertThat(info.isChargingForErrors).isTrue(); // 3 > 2
assertThat(info.lastError).isNotNull();
}
@Test
@DisplayName("cache miss falls back to an empty/zeroed ErrorInfo when no DB row exists")
void cacheMiss_noDbRow_zeroedInfo() {
ErrorTrackingService service = cachedService(2);
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
.thenReturn(Optional.empty());
ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
assertThat(info.currentErrorCount).isZero();
assertThat(info.errorsUntilCharged).isEqualTo(2); // full free allowance
assertThat(info.isChargingForErrors).isFalse();
assertThat(info.lastError).isNull();
}
@Test
@DisplayName("DB-backed (cache disabled): live tracker is reported with derived fields")
void dbBacked_liveTracker_reported() {
ErrorTrackingService service = dbService(2);
UserErrorTracker tracker = new UserErrorTracker(user("h", API_KEY), ENDPOINT, 60);
tracker.setProcessingErrorCount(3);
tracker.setLastProcessingError(LocalDateTime.now());
// not expired (constructor set resetAfter ~60m out)
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
.thenReturn(Optional.of(tracker));
ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
assertThat(info.currentErrorCount).isEqualTo(3);
// getErrorsUntilCharged = max(0, free+1 - current) = max(0, 3 - 3) = 0
assertThat(info.errorsUntilCharged).isZero();
assertThat(info.isChargingForErrors).isTrue(); // 3 > 2
assertThat(info.lastError).isNotNull();
verify(errorTrackerRepository, never()).save(any());
}
@Test
@DisplayName("DB-backed: expired tracker is reset, persisted and reported as zeroed")
void dbBacked_expiredTracker_resetAndZeroed() {
ErrorTrackingService service = dbService(2);
UserErrorTracker tracker = new UserErrorTracker(user("i", API_KEY), ENDPOINT, 60);
tracker.setProcessingErrorCount(7);
tracker.setResetAfter(LocalDateTime.now().minusMinutes(1)); // expired
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
.thenReturn(Optional.of(tracker));
ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
assertThat(info.currentErrorCount).isZero();
assertThat(info.errorsUntilCharged).isEqualTo(2);
assertThat(info.isChargingForErrors).isFalse();
assertThat(info.lastError).isNull();
assertThat(tracker.getProcessingErrorCount()).isZero(); // reset mutated the entity
verify(errorTrackerRepository).save(tracker);
}
}
@Nested
@DisplayName("cleanupExpiredErrorTrackers")
class Cleanup {
@Test
@DisplayName("delegates to the repository delete with a 'now' cutoff")
void delegatesDelete() {
ErrorTrackingService service = cachedService(2);
when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class)))
.thenReturn(4);
service.cleanupExpiredErrorTrackers();
verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class));
}
@Test
@DisplayName("swallows repository exceptions so the scheduler keeps running")
void swallowsRepositoryException() {
ErrorTrackingService service = cachedService(2);
when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class)))
.thenThrow(new RuntimeException("delete blew up"));
// Must not propagate.
service.cleanupExpiredErrorTrackers();
verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class));
}
@Test
@DisplayName("zero deletions still completes cleanly")
void zeroDeletions_ok() {
ErrorTrackingService service = cachedService(2);
when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class)))
.thenReturn(0);
service.cleanupExpiredErrorTrackers();
verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class));
}
}
@Nested
@DisplayName("ErrorInfo value holder")
class ErrorInfoHolder {
@Test
@DisplayName("constructor wires the public fields verbatim")
void fieldsWiredVerbatim() {
LocalDateTime ts = LocalDateTime.now();
ErrorInfo info = new ErrorInfo(5, 1, true, ts);
assertThat(info.currentErrorCount).isEqualTo(5);
assertThat(info.errorsUntilCharged).isEqualTo(1);
assertThat(info.isChargingForErrors).isTrue();
assertThat(info.lastError).isEqualTo(ts);
}
}
@Nested
@DisplayName("API key masking is exercised without leaking (smoke via logging branches)")
class MaskingSmoke {
@Test
@DisplayName("short API keys are tolerated end-to-end on the cache path")
void shortApiKey_tolerated() {
ErrorTrackingService service = cachedService(2);
when(userRepository.findByApiKey(anyString())).thenReturn(Optional.empty());
// "key" is < 8 chars, masked as *** inside the service; must not throw on persist path.
boolean c1 =
service.recordErrorAndShouldConsumeCredit(
"key", ENDPOINT, processingThrowable(), 200);
boolean c2 =
service.recordErrorAndShouldConsumeCredit(
"key", ENDPOINT, processingThrowable(), 200);
boolean c3 =
service.recordErrorAndShouldConsumeCredit(
"key", ENDPOINT, processingThrowable(), 200);
assertThat(c1).isFalse();
assertThat(c2).isFalse();
assertThat(c3).isTrue();
// user absent -> no save even at threshold crossing
verify(errorTrackerRepository, never()).save(any());
}
@Test
@DisplayName("null API key is tolerated on the DB fallback path")
void nullApiKey_tolerated() {
ErrorTrackingService service = dbService(2);
when(userRepository.findByApiKey(eq(null))).thenReturn(Optional.empty());
boolean charge =
service.recordErrorAndShouldConsumeCredit(
null, ENDPOINT, processingThrowable(), 200);
assertThat(charge).isFalse();
verify(errorTrackerRepository, never()).save(any());
}
}
@Test
@DisplayName("IOException as a system error type does not charge even at 200")
void ioExceptionSystemError_doesNotCharge() {
ErrorTrackingService service = cachedService(2);
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, new IOException("disk gone"), 200);
assertThat(charge).isFalse();
verify(errorTrackerRepository, never()).save(any());
}
}
@@ -51,6 +51,7 @@ class SaasUserAccountServiceTest {
@Mock private SupabaseUserService supabaseUserService;
@Mock private SaasUserExtensionService saasUserExtensionService;
@Mock private SaasTeamExtensionService saasTeamExtensionService;
@Mock private SaasTeamService saasTeamService;
@InjectMocks private SaasUserAccountService service;
@@ -355,6 +356,8 @@ class SaasUserAccountServiceTest {
assertThat(u.getEmail()).isEqualTo("alice@example.com");
assertThat(u.getUsername()).isEqualTo("alice@example.com");
verify(userService).saveUser(u);
// Upgrading from anon gives the user their own team.
verify(saasTeamService).ensurePersonalTeam(u);
}
@Test
@@ -455,6 +458,7 @@ class SaasUserAccountServiceTest {
assertThat(u.getUsername()).isEqualTo("existing@example.com");
assertThat(u.getAuthenticationType()).isEqualTo("web");
verify(userService, never()).saveUser(any());
verify(saasTeamService, never()).ensurePersonalTeam(any());
}
@Test
@@ -11,7 +11,7 @@ import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/**
* Pins the contract {@code CreditService.scheduleStripeReportAfterCommit} relies on: a {@link
* Pins the contract {@code JobChargeService.close} relies on for its Stripe meter post: a {@link
* TransactionSynchronization#afterCommit()} hook fires after a successful commit and never on
* rollback.
*/
@@ -1,612 +0,0 @@
package stirling.software.saas.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.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.slf4j.MDC;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.billing.service.StripeUsageReportingService;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.model.CreditConsumptionResult;
import stirling.software.saas.model.TeamCredit;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.repository.TeamCreditRepository;
import stirling.software.saas.repository.TeamMembershipRepository;
/**
* Unit tests for {@link TeamCreditService}. Collaborators (repositories, billing/extension
* services) are mocked; credit math is pure arithmetic so exact numbers are asserted. The waterfall
* path is exercised across every branch: pool hit, no leader, metered-billing disabled, missing
* Supabase id, Stripe report success/failure, and a thrown exception during reporting.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class TeamCreditServiceTest {
@Mock private TeamCreditRepository teamCreditRepository;
@Mock private TeamMembershipRepository membershipRepository;
@Mock private StripeUsageReportingService stripeUsageReportingService;
@Mock private SaasUserExtensionService saasUserExtensionService;
// Real CreditsProperties carries the production default allocations (ROLE_PRO_USER -> 500).
private final CreditsProperties creditsProperties = new CreditsProperties();
private TeamCreditService service;
@BeforeEach
void setUp() {
service =
new TeamCreditService(
teamCreditRepository,
membershipRepository,
creditsProperties,
stripeUsageReportingService,
saasUserExtensionService);
}
@AfterEach
void clearMdc() {
MDC.clear();
}
// ----------------------------------------------------------------------------------------
// Fixtures
// ----------------------------------------------------------------------------------------
private static Team team(Long id) {
Team t = new Team();
t.setId(id);
return t;
}
private static User user(Long id, String username) {
User u = new User();
u.setId(id);
u.setUsername(username);
return u;
}
private static TeamCredit credit(int cycleRemaining, int boughtRemaining) {
TeamCredit c = new TeamCredit();
c.setCycleCreditsRemaining(cycleRemaining);
c.setCycleCreditsAllocated(cycleRemaining);
c.setBoughtCreditsRemaining(boughtRemaining);
c.setTotalBoughtCredits(boughtRemaining);
return c;
}
private static TeamMembership leaderMembership(User leader) {
TeamMembership m = new TeamMembership();
m.setRole(TeamRole.LEADER);
m.setUser(leader);
return m;
}
// ----------------------------------------------------------------------------------------
// initializeTeamCredits
// ----------------------------------------------------------------------------------------
@Nested
@DisplayName("initializeTeamCredits")
class InitializeTeamCredits {
@Test
@DisplayName("returns the existing row without saving when credits already exist")
void returnsExistingWithoutSaving() {
Team team = team(100L);
TeamCredit existing = credit(123, 0);
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(existing));
TeamCredit result = service.initializeTeamCredits(team, user(1L, "primary"));
assertThat(result).isSameAs(existing);
verify(teamCreditRepository, never()).save(any());
}
@Test
@DisplayName("seeds the fixed PRO allocation (500) into both cycle fields for a new team")
void seedsFixedProAllocation() {
Team team = team(100L);
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
when(teamCreditRepository.save(any(TeamCredit.class)))
.thenAnswer(inv -> inv.getArgument(0));
TeamCredit result = service.initializeTeamCredits(team, user(1L, "primary"));
// 500 is the production ROLE_PRO_USER default in CreditsProperties.
assertThat(result.getCycleCreditsAllocated()).isEqualTo(500);
assertThat(result.getCycleCreditsRemaining()).isEqualTo(500);
assertThat(result.getLastCycleResetAt()).isNotNull();
assertThat(result.getTeam()).isSameAs(team);
ArgumentCaptor<TeamCredit> captor = ArgumentCaptor.forClass(TeamCredit.class);
verify(teamCreditRepository).save(captor.capture());
assertThat(captor.getValue().getCycleCreditsRemaining()).isEqualTo(500);
}
@Test
@DisplayName("honours a custom ROLE_PRO_USER allocation from properties")
void honoursCustomAllocation() {
CreditsProperties custom = new CreditsProperties();
custom.getCycle().setAllocations(Map.of("ROLE_PRO_USER", 750));
TeamCreditService svc =
new TeamCreditService(
teamCreditRepository,
membershipRepository,
custom,
stripeUsageReportingService,
saasUserExtensionService);
Team team = team(100L);
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
when(teamCreditRepository.save(any(TeamCredit.class)))
.thenAnswer(inv -> inv.getArgument(0));
TeamCredit result = svc.initializeTeamCredits(team, user(1L, "primary"));
assertThat(result.getCycleCreditsAllocated()).isEqualTo(750);
assertThat(result.getCycleCreditsRemaining()).isEqualTo(750);
}
@Test
@DisplayName("falls back to 500 when the allocation map has no ROLE_PRO_USER entry")
void fallsBackTo500WhenKeyMissing() {
CreditsProperties custom = new CreditsProperties();
custom.getCycle().setAllocations(Map.of("ROLE_USER", 50));
TeamCreditService svc =
new TeamCreditService(
teamCreditRepository,
membershipRepository,
custom,
stripeUsageReportingService,
saasUserExtensionService);
Team team = team(100L);
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
when(teamCreditRepository.save(any(TeamCredit.class)))
.thenAnswer(inv -> inv.getArgument(0));
TeamCredit result = svc.initializeTeamCredits(team, user(1L, "primary"));
assertThat(result.getCycleCreditsAllocated()).isEqualTo(500);
assertThat(result.getCycleCreditsRemaining()).isEqualTo(500);
}
}
// ----------------------------------------------------------------------------------------
// hasCreditsAvailable
// ----------------------------------------------------------------------------------------
@Nested
@DisplayName("hasCreditsAvailable")
class HasCreditsAvailable {
@Test
@DisplayName("true when the team pool has cycle or bought credits left")
void trueWhenCreditsRemain() {
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(credit(3, 0)));
assertThat(service.hasCreditsAvailable(100L)).isTrue();
}
@Test
@DisplayName("true when only bought credits remain")
void trueWhenOnlyBoughtRemain() {
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(credit(0, 5)));
assertThat(service.hasCreditsAvailable(100L)).isTrue();
}
@Test
@DisplayName("false when the pool is empty")
void falseWhenPoolEmpty() {
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(credit(0, 0)));
assertThat(service.hasCreditsAvailable(100L)).isFalse();
}
@Test
@DisplayName("false when no credit row exists for the team")
void falseWhenNoRow() {
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
assertThat(service.hasCreditsAvailable(100L)).isFalse();
}
}
// ----------------------------------------------------------------------------------------
// consumeCredit
// ----------------------------------------------------------------------------------------
@Nested
@DisplayName("consumeCredit")
class ConsumeCredit {
@Test
@DisplayName("true when the atomic update affected a row")
void trueWhenRowUpdated() {
when(teamCreditRepository.consumeCredit(100L, 2)).thenReturn(1);
assertThat(service.consumeCredit(100L, 2)).isTrue();
verify(teamCreditRepository).consumeCredit(100L, 2);
}
@Test
@DisplayName("false when the atomic update affected no rows (insufficient / conflict)")
void falseWhenNoRowUpdated() {
when(teamCreditRepository.consumeCredit(100L, 5)).thenReturn(0);
assertThat(service.consumeCredit(100L, 5)).isFalse();
}
}
// ----------------------------------------------------------------------------------------
// getCreditSummaryForUser
// ----------------------------------------------------------------------------------------
@Nested
@DisplayName("getCreditSummaryForUser")
class GetCreditSummaryForUser {
@Test
@DisplayName("empty when the user has no team assigned")
void emptyWhenNoTeam() {
User u = user(7L, "noteam");
u.setTeam(null);
Optional<TeamCredit> result = service.getCreditSummaryForUser(u);
assertThat(result).isEmpty();
verifyNoInteractions(teamCreditRepository);
}
@Test
@DisplayName("delegates to the repository for the user's team id")
void delegatesToRepository() {
User u = user(7L, "withteam");
u.setTeam(team(200L));
TeamCredit row = credit(10, 0);
when(teamCreditRepository.findByTeamId(200L)).thenReturn(Optional.of(row));
Optional<TeamCredit> result = service.getCreditSummaryForUser(u);
assertThat(result).containsSame(row);
verify(teamCreditRepository).findByTeamId(200L);
}
}
// ----------------------------------------------------------------------------------------
// getTeamCredits
// ----------------------------------------------------------------------------------------
@Nested
@DisplayName("getTeamCredits")
class GetTeamCredits {
@Test
@DisplayName("passes through the repository optional when present")
void presentPassesThrough() {
TeamCredit row = credit(5, 5);
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(row));
assertThat(service.getTeamCredits(100L)).containsSame(row);
}
@Test
@DisplayName("empty when the repository has no row")
void emptyWhenAbsent() {
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
assertThat(service.getTeamCredits(100L)).isEmpty();
}
}
// ----------------------------------------------------------------------------------------
// addBoughtCredits
// ----------------------------------------------------------------------------------------
@Nested
@DisplayName("addBoughtCredits")
class AddBoughtCredits {
@Test
@DisplayName("adds to bought and total counters then saves")
void addsAndSaves() {
TeamCredit row = credit(0, 10);
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(row));
service.addBoughtCredits(100L, 25);
assertThat(row.getBoughtCreditsRemaining()).isEqualTo(35);
assertThat(row.getTotalBoughtCredits()).isEqualTo(35);
verify(teamCreditRepository).save(row);
}
@Test
@DisplayName("throws IllegalArgumentException when no credit row exists")
void throwsWhenMissing() {
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.addBoughtCredits(100L, 25))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Team credits not found");
verify(teamCreditRepository, never()).save(any());
}
}
// ----------------------------------------------------------------------------------------
// resetCycleCredits
// ----------------------------------------------------------------------------------------
@Nested
@DisplayName("resetCycleCredits")
class ResetCycleCredits {
@Test
@DisplayName("overwrites cycle allocation/remaining and reset timestamp then saves")
void resetsAndSaves() {
TeamCredit row = credit(1, 0);
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(row));
LocalDateTime resetTime = LocalDateTime.of(2026, 1, 1, 2, 0);
service.resetCycleCredits(100L, 600, resetTime);
assertThat(row.getCycleCreditsAllocated()).isEqualTo(600);
assertThat(row.getCycleCreditsRemaining()).isEqualTo(600);
assertThat(row.getLastCycleResetAt()).isEqualTo(resetTime);
verify(teamCreditRepository).save(row);
}
@Test
@DisplayName("throws IllegalArgumentException when no credit row exists")
void throwsWhenMissing() {
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.resetCycleCredits(100L, 600, LocalDateTime.now()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Team credits not found");
verify(teamCreditRepository, never()).save(any());
}
}
// ----------------------------------------------------------------------------------------
// consumeCreditWithWaterfall
// ----------------------------------------------------------------------------------------
@Nested
@DisplayName("consumeCreditWithWaterfall")
class ConsumeCreditWithWaterfall {
@Test
@DisplayName("pool hit returns success(TEAM_CREDITS) and never touches overage billing")
void poolHitShortCircuits() {
when(teamCreditRepository.consumeCredit(100L, 3)).thenReturn(1);
CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 3);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getSource()).isEqualTo("TEAM_CREDITS");
verifyNoInteractions(membershipRepository);
verifyNoInteractions(saasUserExtensionService);
verifyNoInteractions(stripeUsageReportingService);
}
@Test
@DisplayName("pool exhausted and no leader returns failure(NO_TEAM_LEADER)")
void poolExhaustedNoLeader() {
when(teamCreditRepository.consumeCredit(100L, 3)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of());
CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 3);
assertThat(result.isSuccess()).isFalse();
// failure(reason) sets the message to the reason code.
assertThat(result.getMessage()).isEqualTo("NO_TEAM_LEADER");
verifyNoInteractions(saasUserExtensionService);
verifyNoInteractions(stripeUsageReportingService);
}
@Test
@DisplayName("leader without metered billing returns the no-overage failure message")
void leaderMeteredBillingDisabled() {
User leader = user(9L, "leader");
leader.setSupabaseId(UUID.randomUUID());
when(teamCreditRepository.consumeCredit(100L, 3)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership(leader)));
when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(false);
CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 3);
assertThat(result.isSuccess()).isFalse();
assertThat(result.getMessage()).contains("Team credits exhausted");
verifyNoInteractions(stripeUsageReportingService);
}
@Test
@DisplayName("leader with metered billing but no Supabase id returns LEADER_NO_SUPABASE_ID")
void leaderMissingSupabaseId() {
User leader = user(9L, "leader");
leader.setSupabaseId(null);
when(teamCreditRepository.consumeCredit(100L, 3)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership(leader)));
when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 3);
assertThat(result.isSuccess()).isFalse();
assertThat(result.getMessage()).isEqualTo("LEADER_NO_SUPABASE_ID");
verifyNoInteractions(stripeUsageReportingService);
}
@Test
@DisplayName("successful Stripe overage report returns success(TEAM_LEADER_METERED)")
void overageReportedSuccessfully() {
UUID supabaseId = UUID.randomUUID();
User leader = user(9L, "leader");
leader.setSupabaseId(supabaseId);
when(teamCreditRepository.consumeCredit(100L, 4)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership(leader)));
when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
when(stripeUsageReportingService.generateIdempotencyKey(
eq(supabaseId.toString()), eq(4), anyString()))
.thenReturn("idem-key");
when(stripeUsageReportingService.reportUsageToStripe(
supabaseId.toString(), 4, "idem-key"))
.thenReturn(true);
CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 4);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getSource()).isEqualTo("TEAM_LEADER_METERED");
verify(stripeUsageReportingService)
.reportUsageToStripe(supabaseId.toString(), 4, "idem-key");
}
@Test
@DisplayName("uses the MDC requestId as the stable operation id for the idempotency key")
void usesMdcRequestIdForIdempotency() {
UUID supabaseId = UUID.randomUUID();
User leader = user(9L, "leader");
leader.setSupabaseId(supabaseId);
MDC.put("requestId", "req-123");
when(teamCreditRepository.consumeCredit(100L, 2)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership(leader)));
when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
when(stripeUsageReportingService.generateIdempotencyKey(
anyString(), anyInt(), anyString()))
.thenReturn("idem-key");
when(stripeUsageReportingService.reportUsageToStripe(
anyString(), anyInt(), anyString()))
.thenReturn(true);
service.consumeCreditWithWaterfall(100L, 2);
verify(stripeUsageReportingService)
.generateIdempotencyKey(supabaseId.toString(), 2, "req-123");
}
@Test
@DisplayName("falls back to a random operation id when no requestId is in MDC")
void generatesRandomOperationIdWhenNoMdc() {
UUID supabaseId = UUID.randomUUID();
User leader = user(9L, "leader");
leader.setSupabaseId(supabaseId);
// No MDC requestId set -> a random UUID string is generated.
when(teamCreditRepository.consumeCredit(100L, 2)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership(leader)));
when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
when(stripeUsageReportingService.generateIdempotencyKey(
anyString(), anyInt(), anyString()))
.thenReturn("idem-key");
when(stripeUsageReportingService.reportUsageToStripe(
anyString(), anyInt(), anyString()))
.thenReturn(true);
CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 2);
assertThat(result.isSuccess()).isTrue();
ArgumentCaptor<String> opId = ArgumentCaptor.forClass(String.class);
verify(stripeUsageReportingService)
.generateIdempotencyKey(eq(supabaseId.toString()), eq(2), opId.capture());
assertThat(opId.getValue()).isNotBlank();
}
@Test
@DisplayName("Stripe report returning false yields failure(STRIPE_REPORTING_FAILED)")
void overageReportFailed() {
UUID supabaseId = UUID.randomUUID();
User leader = user(9L, "leader");
leader.setSupabaseId(supabaseId);
when(teamCreditRepository.consumeCredit(100L, 4)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership(leader)));
when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
when(stripeUsageReportingService.generateIdempotencyKey(
anyString(), anyInt(), anyString()))
.thenReturn("idem-key");
when(stripeUsageReportingService.reportUsageToStripe(
anyString(), anyInt(), anyString()))
.thenReturn(false);
CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 4);
assertThat(result.isSuccess()).isFalse();
assertThat(result.getMessage()).contains("Unable to report usage to Stripe");
}
@Test
@DisplayName("exception during reporting is caught and surfaced as STRIPE_REPORTING_ERROR")
void overageReportThrows() {
UUID supabaseId = UUID.randomUUID();
User leader = user(9L, "leader");
leader.setSupabaseId(supabaseId);
when(teamCreditRepository.consumeCredit(100L, 4)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership(leader)));
when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
when(stripeUsageReportingService.generateIdempotencyKey(
anyString(), anyInt(), anyString()))
.thenReturn("idem-key");
when(stripeUsageReportingService.reportUsageToStripe(
anyString(), anyInt(), anyString()))
.thenThrow(new RuntimeException("boom"));
CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 4);
assertThat(result.isSuccess()).isFalse();
assertThat(result.getMessage()).contains("Error reporting usage: boom");
}
@Test
@DisplayName("picks the first leader when several leaders exist on the team")
void picksFirstLeaderOfMany() {
UUID firstSupabaseId = UUID.randomUUID();
User first = user(1L, "first-leader");
first.setSupabaseId(firstSupabaseId);
User second = user(2L, "second-leader");
second.setSupabaseId(UUID.randomUUID());
when(teamCreditRepository.consumeCredit(100L, 1)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership(first), leaderMembership(second)));
when(saasUserExtensionService.isMeteredBillingEnabled(first)).thenReturn(true);
when(stripeUsageReportingService.generateIdempotencyKey(
anyString(), anyInt(), anyString()))
.thenReturn("idem-key");
when(stripeUsageReportingService.reportUsageToStripe(
anyString(), anyInt(), anyString()))
.thenReturn(true);
service.consumeCreditWithWaterfall(100L, 1);
// Only the first leader's identity is checked / reported on.
verify(saasUserExtensionService).isMeteredBillingEnabled(first);
verify(saasUserExtensionService, never()).isMeteredBillingEnabled(second);
verify(stripeUsageReportingService)
.generateIdempotencyKey(eq(firstSupabaseId.toString()), eq(1), anyString());
}
}
}
@@ -1,13 +1,9 @@
package stirling.software.saas.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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;
@@ -25,20 +21,12 @@ import stirling.software.proprietary.security.database.repository.AuthorityRepos
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.config.CreditsProperties;
/**
* Unit tests for {@link UserRoleService}.
*
* <p>The service is a thin orchestrator: it flips a user's {@link Authority} row, mirrors the role
* into the denormalized {@code roleName} column, and (for the upgrade/downgrade helpers) resets the
* cycle credit allocation via {@link CreditService}. All collaborators are mocked; allocation math
* is pure arithmetic and asserted exactly.
*
* <p>Allocation note: {@link CreditsProperties}'s default map already carries ROLE_USER=50 and
* ROLE_PRO_USER=500, so {@code getOrDefault} returns those, NOT the inline 25/100 fallbacks baked
* into the service. The inline fallbacks only surface when the allocations map lacks the key, which
* is exercised separately with an emptied map.
* <p>The service is a thin orchestrator: it flips a user's {@link Authority} row and mirrors the
* role into the denormalized {@code roleName} column. All collaborators are mocked.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@@ -46,33 +34,12 @@ class UserRoleServiceTest {
@Mock private UserRepository userRepository;
@Mock private AuthorityRepository authorityRepository;
@Mock private CreditService creditService;
private static final String ROLE_USER = Role.USER.getRoleId(); // "ROLE_USER"
private static final String ROLE_PRO_USER = Role.PRO_USER.getRoleId(); // "ROLE_PRO_USER"
/** Build the service with the supplied properties (allocations differ per test). */
private UserRoleService service(CreditsProperties props) {
return new UserRoleService(userRepository, authorityRepository, creditService, props);
}
/** Default properties: allocations map carries ROLE_USER=50, ROLE_PRO_USER=500. */
private static CreditsProperties defaultProps() {
return new CreditsProperties();
}
/** Properties whose allocations map is empty, forcing the service's inline 25/100 fallbacks. */
private static CreditsProperties emptyAllocationsProps() {
CreditsProperties p = new CreditsProperties();
p.getCycle().setAllocations(new HashMap<>());
return p;
}
/** Properties whose allocations map holds the exact values we want to assert against. */
private static CreditsProperties propsWith(Map<String, Integer> allocations) {
CreditsProperties p = new CreditsProperties();
p.getCycle().setAllocations(new HashMap<>(allocations));
return p;
private UserRoleService service() {
return new UserRoleService(userRepository, authorityRepository);
}
private static User user(long id, String username, String currentRole) {
@@ -107,7 +74,7 @@ class UserRoleServiceTest {
@Test
@DisplayName("flips the Authority row, mirrors roleName, and persists both")
void flipsAuthorityAndMirrorsRoleName() {
UserRoleService service = service(defaultProps());
UserRoleService service = service();
User u = user(42L, "alice@example.com", ROLE_USER);
Authority auth = authority(ROLE_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
@@ -120,15 +87,12 @@ class UserRoleServiceTest {
// Denormalized column mirrored on the User entity and saved.
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_PRO_USER);
verify(userRepository).save(u);
// No credit reset on the bare changeRole path.
verify(creditService, never())
.resetCycleAllocationForRoleChange(Mockito.anyLong(), Mockito.anyInt());
}
@Test
@DisplayName("persists the authority before the user (authority-first ordering)")
void persistsAuthorityBeforeUser() {
UserRoleService service = service(defaultProps());
UserRoleService service = service();
User u = user(42L, "alice@example.com", ROLE_USER);
Authority auth = authority(ROLE_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
@@ -143,7 +107,7 @@ class UserRoleServiceTest {
@Test
@DisplayName("looks the authority up by the user's numeric id")
void looksUpAuthorityByUserId() {
UserRoleService service = service(defaultProps());
UserRoleService service = service();
User u = user(99L, "bob@example.com", ROLE_PRO_USER);
Authority auth = authority(ROLE_PRO_USER);
when(authorityRepository.findByUserId(99L)).thenReturn(auth);
@@ -158,7 +122,7 @@ class UserRoleServiceTest {
@Test
@DisplayName("setting the same role is a harmless no-op rewrite that still persists")
void sameRoleStillPersists() {
UserRoleService service = service(defaultProps());
UserRoleService service = service();
User u = user(42L, "carol@example.com", ROLE_USER);
Authority auth = authority(ROLE_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
@@ -173,7 +137,7 @@ class UserRoleServiceTest {
@Test
@DisplayName("tolerates an empty authorities set on the User (logging reads roles as \"\")")
void emptyAuthoritiesSetIsTolerated() {
UserRoleService service = service(defaultProps());
UserRoleService service = service();
User u = user(42L, "dave@example.com", null);
// getRolesAsString() joins an empty set -> "" ; must not NPE in the debug log.
Authority auth = authority(null);
@@ -191,9 +155,9 @@ class UserRoleServiceTest {
class DowngradeToFree {
@Test
@DisplayName("sets ROLE_USER and resets credits to the configured FREE allocation (50)")
void setsUserRoleAndResetsCreditsFromConfig() {
UserRoleService service = service(defaultProps());
@DisplayName("sets ROLE_USER, mirrors roleName, and persists both")
void setsUserRole() {
UserRoleService service = service();
User u = user(42L, "eve@example.com", ROLE_PRO_USER);
Authority auth = authority(ROLE_PRO_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
@@ -204,49 +168,6 @@ class UserRoleServiceTest {
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_USER);
verify(authorityRepository).save(auth);
verify(userRepository).save(u);
// Config map has ROLE_USER=50, so getOrDefault returns 50 (NOT the inline 25 fallback).
verify(creditService).resetCycleAllocationForRoleChange(42L, 50);
}
@Test
@DisplayName("uses the inline 25 fallback when the allocations map omits ROLE_USER")
void usesInlineFallbackWhenAllocationMissing() {
UserRoleService service = service(emptyAllocationsProps());
User u = user(42L, "frank@example.com", ROLE_PRO_USER);
Authority auth = authority(ROLE_PRO_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.downgradeToFree(u);
verify(creditService).resetCycleAllocationForRoleChange(42L, 25);
}
@Test
@DisplayName("honours an explicit ROLE_USER allocation override")
void honoursExplicitAllocationOverride() {
UserRoleService service = service(propsWith(Map.of(ROLE_USER, 7)));
User u = user(42L, "grace@example.com", ROLE_PRO_USER);
Authority auth = authority(ROLE_PRO_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.downgradeToFree(u);
verify(creditService).resetCycleAllocationForRoleChange(42L, 7);
}
@Test
@DisplayName("changes the role before resetting credits (role flip precedes the reset)")
void changesRoleBeforeResettingCredits() {
UserRoleService service = service(defaultProps());
User u = user(42L, "heidi@example.com", ROLE_PRO_USER);
Authority auth = authority(ROLE_PRO_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.downgradeToFree(u);
InOrder order = Mockito.inOrder(userRepository, creditService);
order.verify(userRepository).save(u);
order.verify(creditService).resetCycleAllocationForRoleChange(42L, 50);
}
}
@@ -255,9 +176,9 @@ class UserRoleServiceTest {
class UpgradeToPro {
@Test
@DisplayName("sets ROLE_PRO_USER and resets credits to the configured PRO allocation (500)")
void setsProRoleAndResetsCreditsFromConfig() {
UserRoleService service = service(defaultProps());
@DisplayName("sets ROLE_PRO_USER, mirrors roleName, and persists both")
void setsProRole() {
UserRoleService service = service();
User u = user(42L, "ivan@example.com", ROLE_USER);
Authority auth = authority(ROLE_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
@@ -268,111 +189,6 @@ class UserRoleServiceTest {
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_PRO_USER);
verify(authorityRepository).save(auth);
verify(userRepository).save(u);
// Config map has ROLE_PRO_USER=500, so getOrDefault returns 500 (NOT inline 100).
verify(creditService).resetCycleAllocationForRoleChange(42L, 500);
}
@Test
@DisplayName("uses the inline 100 fallback when the allocations map omits ROLE_PRO_USER")
void usesInlineFallbackWhenAllocationMissing() {
UserRoleService service = service(emptyAllocationsProps());
User u = user(42L, "judy@example.com", ROLE_USER);
Authority auth = authority(ROLE_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.upgradeToPro(u);
verify(creditService).resetCycleAllocationForRoleChange(42L, 100);
}
@Test
@DisplayName("honours an explicit ROLE_PRO_USER allocation override")
void honoursExplicitAllocationOverride() {
UserRoleService service = service(propsWith(Map.of(ROLE_PRO_USER, 9999)));
User u = user(42L, "mallory@example.com", ROLE_USER);
Authority auth = authority(ROLE_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.upgradeToPro(u);
verify(creditService).resetCycleAllocationForRoleChange(42L, 9999);
}
@Test
@DisplayName("changes the role before resetting credits (role flip precedes the reset)")
void changesRoleBeforeResettingCredits() {
UserRoleService service = service(defaultProps());
User u = user(42L, "niaj@example.com", ROLE_USER);
Authority auth = authority(ROLE_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.upgradeToPro(u);
InOrder order = Mockito.inOrder(userRepository, creditService);
order.verify(userRepository).save(u);
order.verify(creditService).resetCycleAllocationForRoleChange(42L, 500);
}
}
@Nested
@DisplayName("getCreditAllocationForRole")
class GetCreditAllocationForRole {
@Test
@DisplayName("returns the configured allocation when the role is present in the map")
void returnsConfiguredAllocation() {
UserRoleService service = service(defaultProps());
// Default map: ROLE_USER=50, ROLE_PRO_USER=500, ROLE_ADMIN=1000.
assertThat(service.getCreditAllocationForRole(ROLE_USER)).isEqualTo(50);
assertThat(service.getCreditAllocationForRole(ROLE_PRO_USER)).isEqualTo(500);
assertThat(service.getCreditAllocationForRole("ROLE_ADMIN")).isEqualTo(1000);
}
@Test
@DisplayName("ROLE_USER falls back to 25 when missing from the allocations map")
void userRoleFallsBackTo25() {
UserRoleService service = service(emptyAllocationsProps());
assertThat(service.getCreditAllocationForRole(ROLE_USER)).isEqualTo(25);
}
@Test
@DisplayName("any non-ROLE_USER role falls back to 100 when missing from the map")
void nonUserRoleFallsBackTo100() {
UserRoleService service = service(emptyAllocationsProps());
assertThat(service.getCreditAllocationForRole(ROLE_PRO_USER)).isEqualTo(100);
assertThat(service.getCreditAllocationForRole("ROLE_ADMIN")).isEqualTo(100);
assertThat(service.getCreditAllocationForRole("ROLE_UNKNOWN")).isEqualTo(100);
}
@Test
@DisplayName("null role id is treated as non-ROLE_USER and falls back to 100")
void nullRoleIdFallsBackTo100() {
UserRoleService service = service(emptyAllocationsProps());
// getOrDefault(null, ...) misses (no null key); the ternary's equals(null) is false
// -> 100. Must not NPE because Role.USER.getRoleId().equals(roleId) is null-safe.
assertThat(service.getCreditAllocationForRole(null)).isEqualTo(100);
}
@Test
@DisplayName("a configured value of zero is returned verbatim, not replaced by a fallback")
void zeroAllocationReturnedVerbatim() {
UserRoleService service = service(propsWith(Map.of("ROLE_WEB_ONLY_USER", 0)));
assertThat(service.getCreditAllocationForRole("ROLE_WEB_ONLY_USER")).isZero();
}
@Test
@DisplayName("getCreditAllocationForRole never touches the persistence collaborators")
void doesNotTouchRepositories() {
UserRoleService service = service(defaultProps());
service.getCreditAllocationForRole(ROLE_USER);
Mockito.verifyNoInteractions(userRepository, authorityRepository, creditService);
}
}
}
@@ -1,348 +0,0 @@
package stirling.software.saas.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.TeamCredit;
import stirling.software.saas.model.UserCredit;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.SaasTeamExtensionService;
import stirling.software.saas.service.TeamCreditService;
/**
* Unit tests for {@link CreditHeaderUtils#getRemainingCredits(User, CreditService,
* TeamCreditService)}.
*
* <p>The method resolves a remaining-credit balance by routing between two pools:
*
* <ul>
* <li><b>Team pool</b> - used only when the user is NOT a limited-API user, has a team, and that
* team is not "personal" per {@link SaasTeamExtensionService#isPersonal}.
* <li><b>Personal pool</b> - otherwise; looked up by Supabase id first, then API key, else -1.
* </ul>
*
* Any thrown exception is swallowed and yields the sentinel {@code -1}. All collaborators are
* mocked; the assertions are pure arithmetic on the resolved balance.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CreditHeaderUtilsTest {
@Mock private SaasTeamExtensionService saasTeamExtensionService;
@Mock private CreditService creditService;
@Mock private TeamCreditService teamCreditService;
@InjectMocks private CreditHeaderUtils creditHeaderUtils;
private static final long TEAM_ID = 77L;
private static final UUID SUPABASE_ID = UUID.fromString("00000000-0000-0000-0000-000000000123");
private static final String API_KEY = "api-key-abcdef";
// --- builders -------------------------------------------------------------------------------
private static User user() {
User u = new User();
u.setUsername("tester");
return u;
}
private static Team team(Long id) {
Team t = new Team();
t.setId(id);
t.setName("team-" + id);
return t;
}
/** Authority ctor self-registers on the user's authority set. */
private static void grant(User u, String role) {
new Authority(role, u);
}
private static UserCredit userCredit(int cycle, int bought) {
UserCredit c = new UserCredit(user());
c.setCycleCreditsRemaining(cycle);
c.setBoughtCreditsRemaining(bought);
return c;
}
private static TeamCredit teamCredit(int cycle, int bought) {
TeamCredit c = new TeamCredit(team(TEAM_ID));
c.setCycleCreditsRemaining(cycle);
c.setBoughtCreditsRemaining(bought);
return c;
}
private int call(User u) {
return creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService);
}
// --- team pool routing ----------------------------------------------------------------------
@Nested
@DisplayName("team pool path (non-limited user on a non-personal team)")
class TeamPool {
@Test
@DisplayName(
"returns the team's total available credits and never touches personal lookups")
void nonPersonalTeam_usesTeamPool() {
User u = user();
u.setTeam(team(TEAM_ID));
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(teamCreditService.getTeamCredits(TEAM_ID))
.thenReturn(Optional.of(teamCredit(40, 10)));
assertThat(call(u)).isEqualTo(50);
verify(teamCreditService).getTeamCredits(TEAM_ID);
verifyNoInteractions(creditService);
}
@Test
@DisplayName("missing team credit row yields the -1 sentinel")
void nonPersonalTeam_missingRow_returnsMinusOne() {
User u = user();
u.setSupabaseId(SUPABASE_ID);
u.setTeam(team(TEAM_ID));
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(teamCreditService.getTeamCredits(TEAM_ID)).thenReturn(Optional.empty());
assertThat(call(u)).isEqualTo(-1);
// Team path chosen, so the personal supabase lookup must never run.
verifyNoInteractions(creditService);
}
@Test
@DisplayName("zero team credits returns 0, not the sentinel")
void nonPersonalTeam_zeroBalance_returnsZero() {
User u = user();
u.setTeam(team(TEAM_ID));
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(teamCreditService.getTeamCredits(TEAM_ID))
.thenReturn(Optional.of(teamCredit(0, 0)));
assertThat(call(u)).isZero();
}
}
// --- personal pool routing ------------------------------------------------------------------
@Nested
@DisplayName("personal pool path")
class PersonalPool {
@Test
@DisplayName("personal team falls through to the user's individual credits")
void personalTeam_usesPersonalCredits() {
User u = user();
u.setSupabaseId(SUPABASE_ID);
u.setTeam(team(TEAM_ID));
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(true);
when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString()))
.thenReturn(Optional.of(userCredit(15, 5)));
assertThat(call(u)).isEqualTo(20);
// Personal path chosen -> team pool untouched.
verify(teamCreditService, never()).getTeamCredits(any());
}
@Test
@DisplayName(
"no team at all uses personal credits and never consults the extension service")
void noTeam_usesPersonalCredits() {
User u = user();
u.setSupabaseId(SUPABASE_ID);
when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString()))
.thenReturn(Optional.of(userCredit(7, 0)));
assertThat(call(u)).isEqualTo(7);
verifyNoInteractions(saasTeamExtensionService);
verify(teamCreditService, never()).getTeamCredits(any());
}
@Test
@DisplayName("supabase id is preferred over api key when both are present")
void supabaseIdPreferredOverApiKey() {
User u = user();
u.setSupabaseId(SUPABASE_ID);
u.setApiKey(API_KEY);
when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString()))
.thenReturn(Optional.of(userCredit(3, 4)));
assertThat(call(u)).isEqualTo(7);
verify(creditService).getUserCreditsBySupabaseId(SUPABASE_ID.toString());
verify(creditService, never()).getUserCreditsByApiKey(anyString());
}
@Test
@DisplayName("falls back to api key lookup when supabase id is absent")
void apiKeyFallback_whenNoSupabaseId() {
User u = user();
u.setApiKey(API_KEY);
when(creditService.getUserCreditsByApiKey(API_KEY))
.thenReturn(Optional.of(userCredit(9, 1)));
assertThat(call(u)).isEqualTo(10);
verify(creditService).getUserCreditsByApiKey(API_KEY);
verify(creditService, never()).getUserCreditsBySupabaseId(anyString());
}
@Test
@DisplayName("supabase lookup returning empty yields -1")
void supabaseLookupEmpty_returnsMinusOne() {
User u = user();
u.setSupabaseId(SUPABASE_ID);
when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString()))
.thenReturn(Optional.empty());
assertThat(call(u)).isEqualTo(-1);
}
@Test
@DisplayName("api key lookup returning empty yields -1")
void apiKeyLookupEmpty_returnsMinusOne() {
User u = user();
u.setApiKey(API_KEY);
when(creditService.getUserCreditsByApiKey(API_KEY)).thenReturn(Optional.empty());
assertThat(call(u)).isEqualTo(-1);
}
@Test
@DisplayName("neither supabase id nor api key present yields -1 with no lookups")
void noIdentifiers_returnsMinusOne() {
User u = user(); // no supabaseId, no apiKey, no team
assertThat(call(u)).isEqualTo(-1);
verifyNoInteractions(creditService);
verifyNoInteractions(teamCreditService);
}
}
// --- limited-api user override --------------------------------------------------------------
@Nested
@DisplayName("limited-api users always read personal credits")
class LimitedApiOverride {
@Test
@DisplayName("ROLE_LIMITED_API_USER on a non-personal team still reads personal credits")
void limitedApiUser_skipsTeamPool() {
User u = user();
u.setApiKey(API_KEY);
u.setTeam(team(TEAM_ID));
grant(u, "ROLE_LIMITED_API_USER");
when(creditService.getUserCreditsByApiKey(API_KEY))
.thenReturn(Optional.of(userCredit(2, 0)));
assertThat(call(u)).isEqualTo(2);
// The team branch is gated out, so neither the extension nor team-credit services run.
verifyNoInteractions(saasTeamExtensionService);
verify(teamCreditService, never()).getTeamCredits(any());
}
@Test
@DisplayName("ROLE_EXTRA_LIMITED_API_USER also forces the personal pool")
void extraLimitedApiUser_skipsTeamPool() {
User u = user();
u.setSupabaseId(SUPABASE_ID);
u.setTeam(team(TEAM_ID));
grant(u, "ROLE_EXTRA_LIMITED_API_USER");
when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString()))
.thenReturn(Optional.of(userCredit(6, 0)));
assertThat(call(u)).isEqualTo(6);
verifyNoInteractions(saasTeamExtensionService);
verify(teamCreditService, never()).getTeamCredits(any());
}
@Test
@DisplayName(
"a non-limited role does not divert a non-personal team member off the team pool")
void unrelatedRole_keepsTeamPool() {
User u = user();
u.setTeam(team(TEAM_ID));
grant(u, "ROLE_USER");
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(teamCreditService.getTeamCredits(TEAM_ID))
.thenReturn(Optional.of(teamCredit(11, 0)));
assertThat(call(u)).isEqualTo(11);
verify(teamCreditService).getTeamCredits(TEAM_ID);
verifyNoInteractions(creditService);
}
}
// --- error swallowing -----------------------------------------------------------------------
@Nested
@DisplayName("exceptions are swallowed and produce the -1 sentinel")
class ErrorHandling {
@Test
@DisplayName("team credit service throwing is caught and returns -1")
void teamServiceThrows_returnsMinusOne() {
User u = user();
u.setTeam(team(TEAM_ID));
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(teamCreditService.getTeamCredits(TEAM_ID))
.thenThrow(new RuntimeException("db down"));
assertThat(call(u)).isEqualTo(-1);
}
@Test
@DisplayName("credit service throwing on personal lookup is caught and returns -1")
void creditServiceThrows_returnsMinusOne() {
User u = user();
u.setSupabaseId(SUPABASE_ID);
when(creditService.getUserCreditsBySupabaseId(anyString()))
.thenThrow(new RuntimeException("lookup boom"));
assertThat(call(u)).isEqualTo(-1);
}
@Test
@DisplayName(
"extension service throwing during personal-team check is caught and returns -1")
void extensionServiceThrows_returnsMinusOne() {
User u = user();
u.setSupabaseId(SUPABASE_ID);
u.setTeam(team(TEAM_ID));
when(saasTeamExtensionService.isPersonal(u.getTeam()))
.thenThrow(new RuntimeException("extension boom"));
assertThat(call(u)).isEqualTo(-1);
}
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.12.0'
version = '2.13.0'
configurations.configureEach {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
+5 -1
View File
@@ -31,9 +31,13 @@ ARG STIRLING_FLAVOR=proprietary
ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
# buildWithFrontend=false → backend-only JAR with API landing page.
RUN STIRLING_FLAVOR=${STIRLING_FLAVOR} \
# Bundle only the JPDFium native for this image's target arch.
ARG TARGETARCH
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
STIRLING_FLAVOR=${STIRLING_FLAVOR} \
gradle clean build \
-PbuildWithFrontend=false \
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
-PprototypesMode=${PROTOTYPES_BUILD} \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
--no-daemon
+5 -1
View File
@@ -43,9 +43,13 @@ ARG PROTOTYPES_BUILD=false
ARG STIRLING_FLAVOR=proprietary
ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
RUN STIRLING_FLAVOR=${STIRLING_FLAVOR} \
# Bundle only the JPDFium native for this image's target arch.
ARG TARGETARCH
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
STIRLING_FLAVOR=${STIRLING_FLAVOR} \
gradle clean build \
-PbuildWithFrontend=true \
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
-PprototypesMode=${PROTOTYPES_BUILD} \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
--no-daemon
+5 -1
View File
@@ -40,9 +40,13 @@ RUN gradle dependencies --no-daemon || true
COPY . .
RUN DISABLE_ADDITIONAL_FEATURES=false \
# Bundle only the JPDFium native for this image's target arch.
ARG TARGETARCH
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
DISABLE_ADDITIONAL_FEATURES=false \
gradle clean build \
-PbuildWithFrontend=true \
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
--no-daemon
+19 -13
View File
@@ -39,17 +39,23 @@ RUN ./gradlew dependencies --no-daemon || true
# Copy entire project
COPY . .
# Build ultra-lite JAR with embedded frontend (minimal features)
RUN DISABLE_ADDITIONAL_FEATURES=true \
# Build ultra-lite JAR with embedded frontend (minimal features).
# Bundle only the JPDFium native for this image's target arch.
ARG TARGETARCH
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
DISABLE_ADDITIONAL_FEATURES=true \
./gradlew clean build \
-PbuildWithFrontend=true \
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
--no-daemon
# Stage 2: Runtime image
FROM eclipse-temurin:25-jre-alpine
# glibc base (not Alpine/musl): JPDFium's PDFium natives are glibc-linked.
FROM eclipse-temurin:25-jre-noble@sha256:b27ca47660a8fa837e47a8533b9b1a3a430295cf29ca28d91af4fd121572dc29
ENV LANG=C.UTF-8 \
ENV DEBIAN_FRONTEND=noninteractive \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8
ARG VERSION_TAG
@@ -87,22 +93,22 @@ ENV VERSION_TAG=$VERSION_TAG \
ENDPOINTS_GROUPS_TO_REMOVE=CLI
# Install minimal dependencies
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
apk upgrade --no-cache -a && \
apk add --no-cache \
RUN mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /storage /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps && \
mkdir -p /usr/share/fonts/opentype/noto && \
apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
tzdata \
tini \
bash \
curl \
shadow \
procps \
util-linux && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /storage /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps && \
mkdir -p /usr/share/fonts/opentype/noto && \
rm -rf /var/lib/apt/lists/* && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
userdel -r ubuntu 2>/dev/null || true && \
groupdel ubuntu 2>/dev/null || true && \
groupadd -g 1000 stirlingpdfgroup && useradd -u 1000 -d $HOME -s /bin/bash -g stirlingpdfgroup stirlingpdfuser && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /configs /customFiles /pipeline /storage /tmp/stirling-pdf
# Copy scripts and built artifacts after OS package layer to maximize cache reuse.
+3
View File
@@ -10,6 +10,9 @@ node_modules/
editor/public/vendor/
# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted.
portal/public/mockServiceWorker.js
# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim.
editor/public/og-metadata.json
editor/src/core/data/ogImageMap.json
editor/public/pdfjs*/
editor/public/js/thirdParty/
editor/public/css/cookieconsent.css
@@ -2756,7 +2756,6 @@ tooltip = "Pick colour from screen"
title = "Choose colour"
[common]
available = "available"
back = "Back"
cancel = "Cancel"
close = "Close"
@@ -2774,7 +2773,6 @@ previous = "Previous"
refresh = "Refresh"
retry = "Retry"
save = "Save"
used = "used"
[compare]
clearSelected = "Clear selected"
@@ -3020,9 +3018,7 @@ title = "Upgrade Guest Account"
upgradeButton = "Upgrade Account"
[config.apiKeys]
chartAriaLabel = "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}"
copyKeyAriaLabel = "Copy API key"
creditsRemaining = "Credits Remaining"
description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one."
docsDescription = "Learn more about integrating with Stirling PDF:"
docsLink = "API Documentation"
@@ -3030,14 +3026,9 @@ docsTitle = "API Documentation"
generateError = "We couldn't generate your API key."
goToAccount = "Go to Account"
guestInfo = "Guest users do not receive API keys. Create an account to get an API key you can use in your applications."
includedCredits = "Included credits"
intro = "Use your API key to programmatically access Stirling PDF's processing capabilities."
label = "API Key"
lastApiUse = "Last API Use"
nextReset = "Next Reset"
overlayMessage = "Generate a key to see credits and available credits"
publicKeyAriaLabel = "Public API key"
purchasedCredits = "Purchased credits"
refreshAriaLabel = "Refresh API key"
schemaLink = "API Schema Reference"
usage = "Include this key in the X-API-KEY header with all API requests."
@@ -3613,6 +3604,7 @@ unzip = "Unzip"
updateOnServer = "Update on Server"
uploadSelected = "Upload Files"
uploadToServer = "Upload to Server"
versionHistory = "Version history"
[files]
addFiles = "Add files"
@@ -3663,7 +3655,13 @@ searchPlaceholder = "Search files..."
[fileSidebar.fileItem]
closeViewer = "Close viewer"
delete = "Delete"
moreActions = "More actions"
openInViewer = "Open in viewer"
savedToServer = "Saved to server"
updateOnServer = "Update on server"
uploadToServer = "Upload to server"
versionHistory = "Version history"
[filesPage]
addToWorkspace = "Add to workspace"
@@ -3680,6 +3678,11 @@ closeDetails = "Close details"
create = "Create"
cycleBlocked = "Can't move a folder into one of its own subfolders."
delete = "Delete"
deleteFilesChoiceBody = "Some of these files are saved both on this device and in the cloud. Where should they be deleted from?"
deleteFilesCloudBody = "Delete {{count}} file(s) from the cloud? This cannot be undone."
deleteFilesError = "Could not delete. Try again."
deleteFilesLocalBody = "Delete {{count}} file(s) from this device? This cannot be undone."
deleteFilesTitle = "Delete {{count}} file(s)?"
deleteFolder = "Delete folder"
deleteFolderBody = "Delete folder \"{{name}}\"?"
deleteFolderContents = "Also delete {{count}} file(s) inside the folder"
@@ -3697,6 +3700,7 @@ downloadVersion = "Download this version"
dropOverlay = "Drop files to upload"
dropOverlaySub = "Files start in Local. Use 'Move to' or 'Save to cloud' to organise them into a folder."
file = "File"
fileInfo = "File info"
fileMenu = "File actions"
folder = "Folder"
folderItems = "{{count}} items"
@@ -3716,10 +3720,8 @@ offlineNoFolderEdits = "Server folder sync unavailable - folder changes are disa
open = "Open"
openVersionInWorkspace = "Open in workspace"
originFilter = "Filter by source"
quickView = "Quick view"
refresh = "Refresh from server"
remove = "Delete"
removeConfirm = "Delete {{count}} file(s)? This cannot be undone."
removeVersion = "Remove this version"
rename = "Rename"
renameFolder = "Rename folder"
@@ -3744,10 +3746,12 @@ uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put the
uploadFromMobile = "Upload from Mobile"
versionActions = "Version actions"
versionCollapse = "Collapse middle versions"
versionHistory = "Version history"
versionHistoryEmpty = "This file has no earlier versions."
versionOrigin = "Original upload"
versionsCount = "{{count}} versions"
versionShowHidden = "Show {{count}} earlier versions"
viewVersion = "View this version"
viewVersionHistory = "Version journey ({{count}})"
appearance.colour = "Colour"
appearance.icon = "Icon"
appearance.title = "Appearance"
@@ -3756,6 +3760,12 @@ column.modified = "Modified"
column.name = "Name"
column.size = "Size"
column.type = "Type"
deleteScope.cloud = "Cloud only"
deleteScope.cloudHint = "Deletes from the server. A copy stays on this device."
deleteScope.device = "This device only"
deleteScope.deviceHint = "Removes the local copy. The cloud copy is kept."
deleteScope.everywhere = "Everywhere"
deleteScope.everywhereHint = "Deletes the file from this device and the cloud."
empty.hint = "Drop PDFs anywhere on this page to upload, or use the New folder button to organise your files."
empty.newFolderCta = "Create folder"
empty.title = "This folder is empty"
@@ -3774,6 +3784,7 @@ empty.sharedByMe.hint = "Create a share link or invite a teammate from any of yo
empty.sharedByMe.title = "You haven't shared any files yet"
error.actionFailed = "Could not {{action}}."
error.actionFailedDetail = "Could not {{action}}: {{message}}"
error.cloudDeleteFailed = "Couldn't delete {{count}} file(s) from the cloud."
error.deleteFolderFailed = "Could not delete folder."
error.deleteFolderFailedDetail = "Could not delete folder: {{message}}"
error.folderAppearanceFailed = "Could not update folder appearance."
@@ -7906,6 +7917,7 @@ valid = "Valid"
[viewer]
cannotPreviewFile = "Cannot Preview File"
copyText = "Copy"
disableColorFilter = "Disable Colour Filter"
dualPageView = "Dual Page View"
enableDarkFilter = "Enable Dark Filter"
@@ -2667,9 +2667,9 @@ agentMenu = "Stirling agent options"
clearChat = "Clear chat"
[chat.input]
disclaimer = "AI can make mistakes. Be sure to verify the output before sharing."
placeholder = "What do you want to do?"
send = "Send message"
disclaimer = "AI can make mistakes. Be sure to verify the output before sharing."
[chat.progress]
analyzing = "Analyzing your request..."
@@ -2730,7 +2730,6 @@ tooltip = "Pick color from screen"
title = "Choose color"
[common]
available = "available"
back = "Back"
cancel = "Cancel"
close = "Close"
@@ -2748,7 +2747,6 @@ previous = "Previous"
refresh = "Refresh"
retry = "Retry"
save = "Save"
used = "used"
[compare]
clearSelected = "Clear selected"
@@ -2994,9 +2992,7 @@ title = "Upgrade Guest Account"
upgradeButton = "Upgrade Account"
[config.apiKeys]
chartAriaLabel = "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}"
copyKeyAriaLabel = "Copy API key"
creditsRemaining = "Credits Remaining"
description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one."
docsDescription = "Learn more about integrating with Stirling PDF:"
docsLink = "API Documentation"
@@ -3004,14 +3000,9 @@ docsTitle = "API Documentation"
generateError = "We couldn't generate your API key."
goToAccount = "Go to Account"
guestInfo = "Guest users do not receive API keys. Create an account to get an API key you can use in your applications."
includedCredits = "Included credits"
intro = "Use your API key to programmatically access Stirling PDF's processing capabilities."
label = "API Key"
lastApiUse = "Last API Use"
nextReset = "Next Reset"
overlayMessage = "Generate a key to see credits and available credits"
publicKeyAriaLabel = "Public API key"
purchasedCredits = "Purchased credits"
refreshAriaLabel = "Refresh API key"
schemaLink = "API Schema Reference"
usage = "Include this key in the X-API-KEY header with all API requests."
@@ -3587,6 +3578,7 @@ unzip = "Unzip"
updateOnServer = "Update on Server"
uploadSelected = "Upload Files"
uploadToServer = "Upload to Server"
versionHistory = "Version history"
[files]
addFiles = "Add files"
@@ -3622,6 +3614,7 @@ workbench = "Workbench"
addFiles = "Add files"
collapse = "Collapse sidebar"
dropHint = "Open files to get started"
dropToAdd = "Drop files to add"
expand = "Expand sidebar"
files = "Files"
googleDrive = "Google Drive"
@@ -3637,7 +3630,13 @@ searchPlaceholder = "Search files..."
[fileSidebar.fileItem]
closeViewer = "Close viewer"
delete = "Delete"
moreActions = "More actions"
openInViewer = "Open in viewer"
savedToServer = "Saved to server"
updateOnServer = "Update on server"
uploadToServer = "Upload to server"
versionHistory = "Version history"
[filesPage]
addToWorkspace = "Add to workspace"
@@ -3654,6 +3653,11 @@ closeDetails = "Close details"
create = "Create"
cycleBlocked = "Can't move a folder into one of its own subfolders."
delete = "Delete"
deleteFilesChoiceBody = "Some of these files are saved both on this device and in the cloud. Where should they be deleted from?"
deleteFilesCloudBody = "Delete {{count}} file(s) from the cloud? This cannot be undone."
deleteFilesError = "Could not delete. Try again."
deleteFilesLocalBody = "Delete {{count}} file(s) from this device? This cannot be undone."
deleteFilesTitle = "Delete {{count}} file(s)?"
deleteFolder = "Delete folder"
deleteFolderBody = "Delete folder \"{{name}}\"?"
deleteFolderContents = "Also delete {{count}} file(s) inside the folder"
@@ -3671,6 +3675,7 @@ downloadVersion = "Download this version"
dropOverlay = "Drop files to upload"
dropOverlaySub = "Files start in Local. Use 'Move to' or 'Save to cloud' to organize them into a folder."
file = "File"
fileInfo = "File info"
fileMenu = "File actions"
folder = "Folder"
folderItems = "{{count}} items"
@@ -3690,10 +3695,8 @@ offlineNoFolderEdits = "Server folder sync unavailable - folder changes are disa
open = "Open"
openVersionInWorkspace = "Open in workspace"
originFilter = "Filter by source"
quickView = "Quick view"
refresh = "Refresh from server"
remove = "Delete"
removeConfirm = "Delete {{count}} file(s)? This cannot be undone."
removeVersion = "Remove this version"
rename = "Rename"
renameFolder = "Rename folder"
@@ -3718,10 +3721,12 @@ uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put the
uploadFromMobile = "Upload from Mobile"
versionActions = "Version actions"
versionCollapse = "Collapse middle versions"
versionHistory = "Version history"
versionHistoryEmpty = "This file has no earlier versions."
versionOrigin = "Original upload"
versionsCount = "{{count}} versions"
versionShowHidden = "Show {{count}} earlier versions"
viewVersion = "View this version"
viewVersionHistory = "Version journey ({{count}})"
appearance.colour = "Color"
appearance.icon = "Icon"
appearance.title = "Appearance"
@@ -3730,6 +3735,12 @@ column.modified = "Modified"
column.name = "Name"
column.size = "Size"
column.type = "Type"
deleteScope.cloud = "Cloud only"
deleteScope.cloudHint = "Deletes from the server. A copy stays on this device."
deleteScope.device = "This device only"
deleteScope.deviceHint = "Removes the local copy. The cloud copy is kept."
deleteScope.everywhere = "Everywhere"
deleteScope.everywhereHint = "Deletes the file from this device and the cloud."
empty.hint = "Drop PDFs anywhere on this page to upload, or use the New folder button to organize your files."
empty.newFolderCta = "Create folder"
empty.title = "This folder is empty"
@@ -3748,6 +3759,7 @@ empty.sharedByMe.hint = "Create a share link or invite a teammate from any of yo
empty.sharedByMe.title = "You haven't shared any files yet"
error.actionFailed = "Could not {{action}}."
error.actionFailedDetail = "Could not {{action}}: {{message}}"
error.cloudDeleteFailed = "Couldn't delete {{count}} file(s) from the cloud."
error.deleteFolderFailed = "Could not delete folder."
error.deleteFolderFailedDetail = "Could not delete folder: {{message}}"
error.folderAppearanceFailed = "Could not update folder appearance."
@@ -6723,8 +6735,9 @@ logout = "Log out"
setAsDefault = "Set as Default"
theme = "Theme"
themeDark = "Dark"
themeDescription = "Switch between light and dark mode"
themeDescription = "Choose light, dark, or follow your system"
themeLight = "Light"
themeSystem = "System"
title = "General"
user = "User"
@@ -7699,8 +7712,6 @@ allReleases = "All Releases"
breaking = "Breaking"
breakingChanges = "Breaking Changes"
breakingChangesDefault = "This version contains breaking changes."
breakingChangesDetected = "Breaking Changes Detected"
breakingChangesMessage = "Some versions contain breaking changes. Please review the migration guides below before updating."
close = "Close"
closeModal = "Close update modal"
current = "Current Version"
@@ -7877,6 +7888,7 @@ valid = "Valid"
[viewer]
cannotPreviewFile = "Cannot Preview File"
copyText = "Copy"
disableColorFilter = "Disable Color Filter"
dualPageView = "Dual Page View"
enableDarkFilter = "Enable Dark Filter"
@@ -1551,7 +1551,7 @@ doc_summary_description = "Riassumi documenti lunghi"
doc_summary_name = "Riassuntore"
form_filler_description = "Compila i moduli PDF in modo intelligente"
form_filler_name = "Compilatore di moduli"
fullscreen_title = "Stirling Agents"
fullscreen_title = "Agenti Stirling"
pdf_to_markdown_description = "Converti PDF in Markdown pulito"
pdf_to_markdown_name = "PDF in Markdown"
section_title = "Agenti"
@@ -4692,9 +4692,9 @@ tags = "layout,disponi,combina"
title = "Layout multipagina"
[home.pdfCommentAgent]
desc = "Chiedi all'AI di annotare un PDF con commenti in stile nota adesiva in base al tuo prompt"
tags = "AI,agente,commento,annotazione,nota adesiva,revisione,feedback,note"
title = "Aggiungi commenti AI"
desc = "Chiedi all'IA di annotare un PDF con commenti in stile nota adesiva in base al tuo prompt"
tags = "IA,agente,commento,annotazione,nota adesiva,revisione,feedback,note"
title = "Aggiungi commenti IA"
[home.pdfOrganiser]
desc = "Rimuovi/Riordina le pagine in qualsiasi ordine."
+640
View File
@@ -0,0 +1,640 @@
{
"default": {
"image": "/og_images/home.png",
"title": "Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"byTool": {
"certSign": {
"image": "/og_images/certSign.png",
"title": "Certificate Sign - Stirling PDF",
"description": "Sign PDF documents using digital certificates"
},
"sign": {
"image": "/og_images/sign.png",
"title": "Sign - Stirling PDF",
"description": "Adds signature to PDF by drawing, text or image"
},
"addText": {
"image": "/og_images/add-text.png",
"title": "Add Text - Stirling PDF",
"description": "Add custom text anywhere in your PDF"
},
"addPassword": {
"image": "/og_images/add-password.png",
"title": "Add Password - Stirling PDF",
"description": "Add password protection and restrictions to PDF files"
},
"removePassword": {
"image": "/og_images/remove-password.png",
"title": "Remove Password - Stirling PDF",
"description": "Remove password protection from PDF documents"
},
"removePages": {
"image": "/og_images/remove.png",
"title": "Remove Pages - Stirling PDF",
"description": "Remove specific pages from a PDF document"
},
"removeBlanks": {
"image": "/og_images/remove-blank-pages.png",
"title": "Remove Blank Pages - Stirling PDF",
"description": "Remove blank pages from PDF documents"
},
"removeAnnotations": {
"image": "/og_images/remove-annotations.png",
"title": "Remove Annotations - Stirling PDF",
"description": "Remove annotations and comments from PDF documents"
},
"removeImage": {
"image": "/og_images/remove-image.png",
"title": "Remove Images - Stirling PDF",
"description": "Remove all images from a PDF document"
},
"changePermissions": {
"image": "/og_images/change-permissions.png",
"title": "Change Permissions - Stirling PDF",
"description": "Change document restrictions and permissions"
},
"watermark": {
"image": "/og_images/add-watermark.png",
"title": "Add Watermark - Stirling PDF",
"description": "Add a custom watermark to your PDF document."
},
"sanitize": {
"image": "/og_images/sanitize.png",
"title": "Sanitize - Stirling PDF",
"description": "Remove potentially harmful elements from PDF files"
},
"split": {
"image": "/og_images/split.png",
"title": "Split - Stirling PDF",
"description": "Split PDFs into multiple documents"
},
"merge": {
"image": "/og_images/mergePdfs.png",
"title": "Merge - Stirling PDF",
"description": "Merge multiple PDFs into a single document"
},
"convert": {
"image": "/og_images/convert.png",
"title": "Convert - Stirling PDF",
"description": "Convert files to and from PDF format"
},
"ocr": {
"image": "/og_images/ocr.png",
"title": "OCR - Stirling PDF",
"description": "Extract text from scanned PDFs using Optical Character Recognition"
},
"addImage": {
"image": "/og_images/add-image.png",
"title": "Add Image - Stirling PDF",
"description": "Add images anywhere in your PDF"
},
"rotate": {
"image": "/og_images/rotate.png",
"title": "Rotate - Stirling PDF",
"description": "Easily rotate your PDFs."
},
"annotate": {
"image": "/og_images/annotate.png",
"title": "Annotate - Stirling PDF",
"description": "Highlight, draw, add notes, and shapes directly in the viewer"
},
"scannerImageSplit": {
"image": "/og_images/detect-split-scanned-photos.png",
"title": "Detect & Split Scanned Photos - Stirling PDF",
"description": "Detect and split scanned photos into separate pages"
},
"editTableOfContents": {
"image": "/og_images/edit-table-of-contents.png",
"title": "Edit Table of Contents - Stirling PDF",
"description": "Add or edit bookmarks and table of contents in PDF documents"
},
"scannerEffect": {
"image": "/og_images/scanner-effect.png",
"title": "Scanner Effect - Stirling PDF",
"description": "Create a PDF that looks like it was scanned"
},
"autoRename": {
"image": "/og_images/auto-rename-pdf-file.png",
"title": "Auto Rename PDF File - Stirling PDF",
"description": "Automatically rename PDF files based on their content"
},
"pageLayout": {
"image": "/og_images/multi-page-layout.png",
"title": "Multi-Page Layout - Stirling PDF",
"description": "Merge multiple pages of a PDF document into a single page"
},
"scalePages": {
"image": "/og_images/adjust-page-size-scale.png",
"title": "Adjust page size/scale - Stirling PDF",
"description": "Change the size/scale of a page and/or its contents."
},
"adjustContrast": {
"image": "/og_images/adjust-colors-contrast.png",
"title": "Adjust Colors/Contrast - Stirling PDF",
"description": "Adjust colors and contrast of PDF documents"
},
"crop": {
"image": "/og_images/cropPdf.png",
"title": "Crop PDF - Stirling PDF",
"description": "Crop a PDF to reduce its size (maintains text!)"
},
"pdfToSinglePage": {
"image": "/og_images/single-large-page.png",
"title": "PDF to Single Large Page - Stirling PDF",
"description": "Merges all PDF pages into one large single page"
},
"repair": {
"image": "/og_images/repair.png",
"title": "Repair - Stirling PDF",
"description": "Repair corrupted or damaged PDF files"
},
"compare": {
"image": "/og_images/compare.png",
"title": "Compare - Stirling PDF",
"description": "Compare two PDF documents and highlight differences"
},
"addPageNumbers": {
"image": "/og_images/add-page-numbers.png",
"title": "Add Page Numbers - Stirling PDF",
"description": "Add Page numbers throughout a document in a set location"
},
"redact": {
"image": "/og_images/redact.png",
"title": "Redact - Stirling PDF",
"description": "Permanently remove sensitive information from PDF documents"
},
"flatten": {
"image": "/og_images/flatten.png",
"title": "Flatten - Stirling PDF",
"description": "Remove all interactive elements and forms from a PDF"
},
"removeCertSign": {
"image": "/og_images/remove-certificate-sign.png",
"title": "Remove Certificate Sign - Stirling PDF",
"description": "Remove digital signature from PDF documents"
},
"unlockPDFForms": {
"image": "/og_images/unlock-pdf-forms.png",
"title": "Unlock PDF Forms - Stirling PDF",
"description": "Remove read-only property of form fields in a PDF document."
},
"compress": {
"image": "/og_images/compress.png",
"title": "Compress - Stirling PDF",
"description": "Compress PDFs to reduce their file size."
},
"extractPages": {
"image": "/og_images/extract-pages.png",
"title": "Extract Pages - Stirling PDF",
"description": "Extract specific pages from a PDF document"
},
"reorganizePages": {
"image": "/og_images/reorganize-pages.png",
"title": "Reorganize Pages - Stirling PDF",
"description": "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."
},
"extractImages": {
"image": "/og_images/extract-images.png",
"title": "Extract Images - Stirling PDF",
"description": "Extract images from PDF documents"
},
"addStamp": {
"image": "/og_images/add-stamp.png",
"title": "Add Stamp to PDF - Stirling PDF",
"description": "Add text or add image stamps at set locations"
},
"addAttachments": {
"image": "/og_images/add-attachments.png",
"title": "Add Attachments - Stirling PDF",
"description": "Add or remove embedded files (attachments) to/from a PDF"
},
"changeMetadata": {
"image": "/og_images/change-metadata.png",
"title": "Change Metadata - Stirling PDF",
"description": "Change/Remove/Add metadata from a PDF document"
},
"overlayPdfs": {
"image": "/og_images/overlay-pdfs.png",
"title": "Overlay Pdfs - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"getPdfInfo": {
"image": "/og_images/get-all-info-on-pdf.png",
"title": "Get ALL Info on PDF - Stirling PDF",
"description": "Grabs any and all information possible on PDFs"
},
"validateSignature": {
"image": "/og_images/validate-pdf-signature.png",
"title": "Validate PDF Signature - Stirling PDF",
"description": "Verify digital signatures and certificates in PDF documents"
},
"timestampPdf": {
"image": "/og_images/timestamp-pdf.png",
"title": "Timestamp PDF - Stirling PDF",
"description": "Add an RFC 3161 document timestamp to prove when your PDF existed"
},
"replaceColor": {
"image": "/og_images/replace-and-invert-color.png",
"title": "Replace & Invert Color - Stirling PDF",
"description": "Replace or invert colors in PDF documents"
},
"showJS": {
"image": "/og_images/show-javascript.png",
"title": "Show JavaScript - Stirling PDF",
"description": "Extract and display JavaScript code from PDF documents"
},
"bookletImposition": {
"image": "/og_images/booklet-imposition.png",
"title": "Booklet Imposition - Stirling PDF",
"description": "Create booklets with proper page ordering and multi-page layout for printing and binding"
},
"pdfTextEditor": {
"image": "/og_images/pdf-text-editor.png",
"title": "PDF Text Editor - Stirling PDF",
"description": "Review and edit text and images in PDFs with grouped text editing and PDF regeneration"
},
"formFill": {
"image": "/og_images/form-fill.png",
"title": "Fill Form - Stirling PDF",
"description": "Fill PDF form fields interactively with a visual editor"
},
"multiTool": {
"image": "/og_images/multi-tool.png",
"title": "Multi-Tool - Stirling PDF",
"description": "Use multiple tools on a single PDF document"
},
"read": {
"image": "/og_images/read.png",
"title": "Read - Stirling PDF",
"description": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."
},
"automate": {
"image": "/og_images/automate.png",
"title": "Automate - Stirling PDF",
"description": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
},
"devApi": {
"image": "/og_images/dev-api.png",
"title": "API - Stirling PDF",
"description": "Link to API documentation"
},
"devFolderScanning": {
"image": "/og_images/dev-folder-scanning.png",
"title": "Automated Folder Scanning - Stirling PDF",
"description": "Link to automated folder scanning guide"
},
"devSsoGuide": {
"image": "/og_images/dev-sso-guide.png",
"title": "SSO Guide - Stirling PDF",
"description": "Link to SSO guide"
},
"devAirgapped": {
"image": "/og_images/dev-airgapped.png",
"title": "Air-gapped Setup - Stirling PDF",
"description": "Link to air-gapped setup guide"
},
"/login": {
"image": "/og_images/home.png",
"title": "Sign In - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/signup": {
"image": "/og_images/home.png",
"title": "Sign Up - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/mobile-scanner": {
"image": "/og_images/home.png",
"title": "Mobile Scanner - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/files": {
"image": "/og_images/home.png",
"title": "Files - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings": {
"image": "/og_images/home.png",
"title": "Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/preferences": {
"image": "/og_images/home.png",
"title": "Preferences Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/notifications": {
"image": "/og_images/home.png",
"title": "Notifications Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/connections": {
"image": "/og_images/home.png",
"title": "Connections Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/account": {
"image": "/og_images/home.png",
"title": "Account Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/general": {
"image": "/og_images/home.png",
"title": "General Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/people": {
"image": "/og_images/home.png",
"title": "People Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/teams": {
"image": "/og_images/home.png",
"title": "Teams Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/security": {
"image": "/og_images/home.png",
"title": "Security Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/identity": {
"image": "/og_images/home.png",
"title": "Identity Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/plan": {
"image": "/og_images/home.png",
"title": "Plan Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/payments": {
"image": "/og_images/home.png",
"title": "Payments Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/requests": {
"image": "/og_images/home.png",
"title": "Requests Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/developer": {
"image": "/og_images/home.png",
"title": "Developer Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/api-keys": {
"image": "/og_images/home.png",
"title": "Api Keys Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/hotkeys": {
"image": "/og_images/home.png",
"title": "Hotkeys Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminGeneral": {
"image": "/og_images/home.png",
"title": "Admin General Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminSecurity": {
"image": "/og_images/home.png",
"title": "Admin Security Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminConnections": {
"image": "/og_images/home.png",
"title": "Admin Connections Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminPrivacy": {
"image": "/og_images/home.png",
"title": "Admin Privacy Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminDatabase": {
"image": "/og_images/home.png",
"title": "Admin Database Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminAdvanced": {
"image": "/og_images/home.png",
"title": "Admin Advanced Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminLegal": {
"image": "/og_images/home.png",
"title": "Admin Legal Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminPremium": {
"image": "/og_images/home.png",
"title": "Admin Premium Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminFeatures": {
"image": "/og_images/home.png",
"title": "Admin Features Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminPlan": {
"image": "/og_images/home.png",
"title": "Admin Plan Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminAudit": {
"image": "/og_images/home.png",
"title": "Admin Audit Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminUsage": {
"image": "/og_images/home.png",
"title": "Admin Usage Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminEndpoints": {
"image": "/og_images/home.png",
"title": "Admin Endpoints Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminStorageSharing": {
"image": "/og_images/home.png",
"title": "Admin Storage Sharing Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminMcp": {
"image": "/og_images/home.png",
"title": "Admin Mcp Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/help": {
"image": "/og_images/home.png",
"title": "Help Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/legal": {
"image": "/og_images/home.png",
"title": "Legal Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/payg": {
"image": "/og_images/home.png",
"title": "Payg Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
}
},
"byPath": {
"/cert-sign": "certSign",
"/sign": "sign",
"/add-text": "addText",
"/add-password": "addPassword",
"/remove-password": "removePassword",
"/remove-pages": "removePages",
"/remove-blanks": "removeBlanks",
"/remove-annotations": "removeAnnotations",
"/remove-image": "removeImage",
"/change-permissions": "changePermissions",
"/watermark": "watermark",
"/sanitize": "sanitize",
"/split": "split",
"/merge": "merge",
"/convert": "convert",
"/ocr": "ocr",
"/add-image": "addImage",
"/rotate": "rotate",
"/annotate": "annotate",
"/scanner-image-split": "scannerImageSplit",
"/edit-table-of-contents": "editTableOfContents",
"/scanner-effect": "scannerEffect",
"/auto-rename": "autoRename",
"/page-layout": "pageLayout",
"/scale-pages": "scalePages",
"/adjust-contrast": "adjustContrast",
"/crop": "crop",
"/pdf-to-single-page": "pdfToSinglePage",
"/repair": "repair",
"/compare": "compare",
"/add-page-numbers": "addPageNumbers",
"/redact": "redact",
"/flatten": "flatten",
"/remove-cert-sign": "removeCertSign",
"/unlock-p-d-f-forms": "unlockPDFForms",
"/compress": "compress",
"/extract-pages": "extractPages",
"/reorganize-pages": "reorganizePages",
"/extract-images": "extractImages",
"/add-stamp": "addStamp",
"/add-attachments": "addAttachments",
"/change-metadata": "changeMetadata",
"/overlay-pdfs": "overlayPdfs",
"/get-pdf-info": "getPdfInfo",
"/validate-signature": "validateSignature",
"/timestamp-pdf": "timestampPdf",
"/replace-color": "replaceColor",
"/show-j-s": "showJS",
"/booklet-imposition": "bookletImposition",
"/pdf-text-editor": "pdfTextEditor",
"/form-fill": "formFill",
"/multi-tool": "multiTool",
"/read": "read",
"/automate": "automate",
"/dev-api": "devApi",
"/dev-folder-scanning": "devFolderScanning",
"/dev-sso-guide": "devSsoGuide",
"/dev-airgapped": "devAirgapped",
"/split-pdfs": "split",
"/merge-pdfs": "merge",
"/compress-pdf": "compress",
"/rotate-pdf": "rotate",
"/convert-pdf": "convert",
"/file-to-pdf": "convert",
"/eml-to-pdf": "convert",
"/html-to-pdf": "convert",
"/markdown-to-pdf": "convert",
"/pdf-to-csv": "convert",
"/pdf-to-xlsx": "convert",
"/pdf-to-img": "convert",
"/pdf-to-markdown": "convert",
"/pdf-to-pdfa": "convert",
"/pdf-to-word": "convert",
"/pdf-to-xml": "convert",
"/cbr-to-pdf": "convert",
"/pdf-to-cbr": "convert",
"/cbz-to-pdf": "convert",
"/pdf-to-cbz": "convert",
"/manage-signatures": "certSign",
"/remove-certificate-sign": "removeCertSign",
"/unlock-pdf-forms": "unlockPDFForms",
"/sanitize-pdf": "sanitize",
"/ocr-pdf": "ocr",
"/add-watermark": "watermark",
"/single-large-page": "pdfToSinglePage",
"/auto-split-pdf": "split",
"/auto-size-split-pdf": "split",
"/annotations": "annotate",
"/fake-scan": "scannerEffect",
"/replace-color-pdf": "replaceColor",
"/show-js": "showJS",
"/pdf-organizer": "reorganizePages",
"/multi-page-layout": "pageLayout",
"/extract-page": "extractPages",
"/img-to-pdf": "convert",
"/pdf-to-presentation": "convert",
"/pdf-to-text": "convert",
"/pdf-to-html": "convert",
"/auto-redact": "redact",
"/stamp": "addStamp",
"/view-pdf": "read",
"/get-info-on-pdf": "getPdfInfo",
"/remove-image-pdf": "removeImage",
"/replace-and-invert-color-pdf": "replaceColor",
"/pipeline": "automate",
"/extract-image-scans": "scannerImageSplit",
"/show-javascript": "showJS",
"/split-by-size-or-count": "split",
"/overlay-pdf": "overlayPdfs",
"/split-pdf-by-sections": "split",
"/split-pdf-by-chapters": "split",
"/login": "/login",
"/signup": "/signup",
"/mobile-scanner": "/mobile-scanner",
"/files": "/files",
"/settings": "/settings",
"/settings/preferences": "/settings/preferences",
"/settings/notifications": "/settings/notifications",
"/settings/connections": "/settings/connections",
"/settings/account": "/settings/account",
"/settings/general": "/settings/general",
"/settings/people": "/settings/people",
"/settings/teams": "/settings/teams",
"/settings/security": "/settings/security",
"/settings/identity": "/settings/identity",
"/settings/plan": "/settings/plan",
"/settings/payments": "/settings/payments",
"/settings/requests": "/settings/requests",
"/settings/developer": "/settings/developer",
"/settings/api-keys": "/settings/api-keys",
"/settings/hotkeys": "/settings/hotkeys",
"/settings/adminGeneral": "/settings/adminGeneral",
"/settings/adminSecurity": "/settings/adminSecurity",
"/settings/adminConnections": "/settings/adminConnections",
"/settings/adminPrivacy": "/settings/adminPrivacy",
"/settings/adminDatabase": "/settings/adminDatabase",
"/settings/adminAdvanced": "/settings/adminAdvanced",
"/settings/adminLegal": "/settings/adminLegal",
"/settings/adminPremium": "/settings/adminPremium",
"/settings/adminFeatures": "/settings/adminFeatures",
"/settings/adminPlan": "/settings/adminPlan",
"/settings/adminAudit": "/settings/adminAudit",
"/settings/adminUsage": "/settings/adminUsage",
"/settings/adminEndpoints": "/settings/adminEndpoints",
"/settings/adminStorageSharing": "/settings/adminStorageSharing",
"/settings/adminMcp": "/settings/adminMcp",
"/settings/help": "/settings/help",
"/settings/legal": "/settings/legal",
"/settings/payg": "/settings/payg"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 200 KiB

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