mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
956b8000e4 | ||
|
|
a3fe15bfd0 | ||
|
|
1a770af47c | ||
|
|
3870ac3d7d | ||
|
|
b9ea9064c7 | ||
|
|
fe7a2a5ac7 | ||
|
|
6a9876a067 | ||
|
|
3793a6df52 | ||
|
|
66841db2b7 | ||
|
|
377677c182 | ||
|
|
f25f7e5fc9 | ||
|
|
b57958531d | ||
|
|
f8ceca0c3f | ||
|
|
e6d476297d | ||
|
|
3456316569 | ||
|
|
215bba39bc | ||
|
|
900b66b030 | ||
|
|
c3795c1a3c | ||
|
|
c8925acee7 | ||
|
|
eb08e60d67 | ||
|
|
18da914bf9 | ||
|
|
8f46ca0d92 | ||
|
|
9a3bc6b47f | ||
|
|
2b05865a84 | ||
|
|
0c503cc41d | ||
|
|
b1fef4c647 | ||
|
|
06254853af | ||
|
|
d9e6041a75 | ||
|
|
8f81fdc762 | ||
|
|
13af10a6d1 | ||
|
|
3750111ffc | ||
|
|
20c88feabb | ||
|
|
4f26fdeb5c | ||
|
|
df9dbc5179 | ||
|
|
de9242c4f7 | ||
|
|
460c037bbb | ||
|
|
cd7264a76a | ||
|
|
ef0deef4f2 | ||
|
|
65fcc036fe |
@@ -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,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.12.0
|
||||
pkgver=2.13.1
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.12.0
|
||||
pkgver=2.13.1
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pre-commit
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -115,6 +115,15 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
# Cache the Cargo registry and compiled dependency crates so the build
|
||||
# only recompiles the app crate. Written on main; PRs and the merge queue
|
||||
# restore from it.
|
||||
- name: Cache Rust build
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
workspaces: frontend/editor/src-tauri
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
|
||||
@@ -46,6 +46,12 @@ app/core/storage/
|
||||
# These are generated by npm build and should not be committed
|
||||
app/core/src/main/resources/static/assets/
|
||||
app/core/src/main/resources/static/index.html
|
||||
# Prerendered per-route SPA pages (OG/social-preview), e.g. compress.html. api-landing.html is source.
|
||||
app/core/src/main/resources/static/*.html
|
||||
!app/core/src/main/resources/static/api-landing.html
|
||||
!app/core/src/main/resources/static/mobile-upload.html
|
||||
# Prerendered nested-route pages (e.g. settings/people.html)
|
||||
app/core/src/main/resources/static/settings/
|
||||
app/core/src/main/resources/static/locales/
|
||||
app/core/src/main/resources/static/Login/
|
||||
app/core/src/main/resources/static/classic-logo/
|
||||
@@ -53,6 +59,8 @@ app/core/src/main/resources/static/modern-logo/
|
||||
app/core/src/main/resources/static/og_images/
|
||||
app/core/src/main/resources/static/samples/
|
||||
app/core/src/main/resources/static/manifest-classic.json
|
||||
app/core/src/main/resources/static/og-metadata.json
|
||||
app/core/src/main/resources/static/sw-folder-retry.js
|
||||
app/core/src/main/resources/static/robots.txt
|
||||
app/core/src/main/resources/static/pdfium/
|
||||
app/core/src/main/resources/static/pdfjs/
|
||||
|
||||
+8
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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
|
||||
@@ -262,6 +278,12 @@ tasks:
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/src/desktop/tsconfig.json
|
||||
|
||||
typecheck:cloud:
|
||||
desc: "Typecheck cloud shared layer (standalone)"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project editor/src/cloud/tsconfig.json
|
||||
|
||||
typecheck:scripts:
|
||||
desc: "Typecheck scripts"
|
||||
deps: [prepare]
|
||||
@@ -293,6 +315,7 @@ tasks:
|
||||
- task: typecheck:proprietary
|
||||
- task: typecheck:saas
|
||||
- task: typecheck:desktop
|
||||
- task: typecheck:cloud
|
||||
- task: typecheck:scripts
|
||||
- task: typecheck:prototypes
|
||||
- task: typecheck:portal
|
||||
@@ -310,9 +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
|
||||
@@ -367,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]
|
||||
|
||||
@@ -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]
|
||||
@@ -152,7 +152,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
|
||||
#### Import Paths - CRITICAL
|
||||
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
|
||||
|
||||
For a broader explanation of the frontend layering and override architecture, see [frontend/editor/DeveloperGuide.md](frontend/editor/DeveloperGuide.md).
|
||||
For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use @app/* for all imports
|
||||
@@ -169,7 +169,31 @@ import { useFileContext } from "@proprietary/contexts/FileContext";
|
||||
- Building layer-specific override that wraps a lower layer's component
|
||||
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
|
||||
|
||||
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
|
||||
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/saas/desktop/cloud) and handles the fallback cascade — see "Frontend `cloud/` Layer" below for the full per-flavor order.
|
||||
|
||||
#### Frontend `cloud/` Layer
|
||||
|
||||
`@app/*` resolves through a per-flavor cascade — first existing file wins (shadow/override):
|
||||
|
||||
- **core** → core
|
||||
- **proprietary** → proprietary → core
|
||||
- **saas** → saas → cloud → proprietary → core
|
||||
- **desktop** → desktop → cloud → proprietary → core
|
||||
- **cloud** → cloud → proprietary → core
|
||||
|
||||
What goes where:
|
||||
|
||||
- **core** — OSS base.
|
||||
- **proprietary** — licensed / offline features.
|
||||
- **cloud** — the SHARED hosted/SaaS experience used by BOTH saas + desktop: PAYG, wallet, plan, billing, usage meters, cloud config/team/onboarding.
|
||||
- **saas** — web-only: Supabase web auth, AuthCallback, avatar canvas, `window.location`.
|
||||
- **desktop** — Tauri-only: keyring authService, tauriHttpClient, native files/windows, backend routing.
|
||||
|
||||
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (enforced by ESLint). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
|
||||
|
||||
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
|
||||
|
||||
**Cloud feature flags on desktop.** The local `AppConfigContext` reads `/api/v1/config/app-config` from the LOCAL bundled backend, so cloud-only flags (`aiEngineEnabled`, `premiumEnabled`, …) are never seen on desktop. To read the cloud's view, use `useSaasAppConfig()` (`desktop/hooks/useSaasAppConfig.ts`, backed by the general `saasAppConfigService` — SaaS-mode-only, public endpoint, native HTTP, 5-min cache). It returns `null` outside SaaS mode, so cloud features stay off in local/self-hosted and the server keeps the on/off switch (no desktop release needed to flip a flag). Gate a feature behind a per-platform seam — e.g. `useAiEngineEnabled()` (core reads `useAppConfig()`, desktop reads `useSaasAppConfig()`) — rather than hardcoding the flag on.
|
||||
|
||||
#### Component Override Pattern (Stub/Shadow)
|
||||
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
|
||||
|
||||
@@ -16,6 +16,8 @@ if that directory exists, is licensed under the license defined in "frontend/edi
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/desktop/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/saas/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/saas/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/cloud/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
|
||||
* All content that resides under the "frontend/portal/" directory of this repository,
|
||||
|
||||
@@ -60,7 +60,7 @@ For full installation options (including desktop and Kubernetes), see our [Docum
|
||||
|
||||
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
This project uses [Task](https://taskfile.dev/) as a unified command runner for all build, dev, and test commands. Run `task install` to get started, or see the [Developer Guide](DeveloperGuide.md) for full details.
|
||||
This project uses [Task](https://taskfile.dev/) as a unified command runner for all build, dev, and test commands. Run `task dev` to get started running the editor, run `task` to see the most common commands, or see the [Developer Guide](DeveloperGuide.md) for full details.
|
||||
|
||||
For adding translations, see the [Translation Guide](devGuide/HowToAddNewLanguage.md).
|
||||
|
||||
|
||||
@@ -25,8 +25,28 @@ includes:
|
||||
e2e:
|
||||
taskfile: .taskfiles/e2e.yml
|
||||
dir: .
|
||||
pre-commit:
|
||||
taskfile: .taskfiles/pre-commit.yml
|
||||
dir: .
|
||||
|
||||
tasks:
|
||||
# ============================================================
|
||||
# Help (shown when you run `task` with no arguments)
|
||||
# ============================================================
|
||||
|
||||
default:
|
||||
desc: "List the most common commands"
|
||||
silent: true
|
||||
cmds:
|
||||
- |
|
||||
echo "Common commands (run 'task --list' to see all):"
|
||||
echo ""
|
||||
echo " task dev Start backend & frontend on free ports"
|
||||
echo " task backend:dev Start backend on default port"
|
||||
echo " task frontend:dev Start frontend on default port"
|
||||
echo " task desktop:dev Start desktop app"
|
||||
echo " task check Quality gate (lint, typecheck, test, etc.)"
|
||||
|
||||
# ============================================================
|
||||
# Setup & Prerequisites
|
||||
# ============================================================
|
||||
@@ -87,6 +107,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 +192,6 @@ tasks:
|
||||
desc: "Clean all build artifacts"
|
||||
cmds:
|
||||
- task: backend:clean
|
||||
- task: frontend:clean
|
||||
- task: engine:clean
|
||||
- task: pre-commit:clean
|
||||
|
||||
@@ -1185,23 +1185,181 @@ public class GeneralUtils {
|
||||
}
|
||||
|
||||
public String getLocalNetworkIp() {
|
||||
String routed = detectLocalIpViaDefaultRoute();
|
||||
if (routed != null) {
|
||||
return routed;
|
||||
}
|
||||
try {
|
||||
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
|
||||
if (interfaces == null) return null;
|
||||
while (interfaces.hasMoreElements()) {
|
||||
NetworkInterface iface = interfaces.nextElement();
|
||||
if (!iface.isUp() || iface.isLoopback() || iface.isVirtual()) continue;
|
||||
Enumeration<InetAddress> addresses = iface.getInetAddresses();
|
||||
while (addresses.hasMoreElements()) {
|
||||
InetAddress addr = addresses.nextElement();
|
||||
if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) {
|
||||
return addr.getHostAddress();
|
||||
}
|
||||
}
|
||||
}
|
||||
return selectBestSiteLocalIp(collectInterfaceInfo());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to detect local network IP", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String detectLocalIpViaDefaultRoute() {
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.connect(InetAddress.getByName("8.8.8.8"), 53);
|
||||
InetAddress local = socket.getLocalAddress();
|
||||
if (local instanceof Inet4Address
|
||||
&& !local.isAnyLocalAddress()
|
||||
&& !local.isLoopbackAddress()
|
||||
&& !local.isLinkLocalAddress()) {
|
||||
return local.getHostAddress();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Default-route IP detection failed; will scan interfaces", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<NetworkInterfaceInfo> collectInterfaceInfo() throws SocketException {
|
||||
List<NetworkInterfaceInfo> infos = new ArrayList<>();
|
||||
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
|
||||
if (interfaces == null) {
|
||||
return infos;
|
||||
}
|
||||
while (interfaces.hasMoreElements()) {
|
||||
NetworkInterface iface = interfaces.nextElement();
|
||||
|
||||
List<String> siteLocalIpv4s = new ArrayList<>();
|
||||
Enumeration<InetAddress> addresses = iface.getInetAddresses();
|
||||
while (addresses.hasMoreElements()) {
|
||||
InetAddress addr = addresses.nextElement();
|
||||
if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) {
|
||||
siteLocalIpv4s.add(addr.getHostAddress());
|
||||
}
|
||||
}
|
||||
if (siteLocalIpv4s.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] mac = iface.getHardwareAddress();
|
||||
infos.add(
|
||||
new NetworkInterfaceInfo(
|
||||
iface.getName(),
|
||||
iface.getDisplayName(),
|
||||
iface.getIndex(),
|
||||
iface.isUp(),
|
||||
iface.isLoopback(),
|
||||
iface.isPointToPoint(),
|
||||
iface.isVirtual(),
|
||||
mac != null && mac.length > 0,
|
||||
siteLocalIpv4s));
|
||||
} catch (SocketException e) {
|
||||
log.debug("Skipping interface {} while scanning for local IP", iface.getName(), e);
|
||||
}
|
||||
}
|
||||
return infos;
|
||||
}
|
||||
|
||||
static String selectBestSiteLocalIp(List<NetworkInterfaceInfo> interfaces) {
|
||||
return interfaces.stream()
|
||||
.filter(i -> i.up() && !i.loopback() && !i.pointToPoint() && !i.virtual())
|
||||
.filter(i -> !isLikelyVirtualInterface(i.name(), i.displayName()))
|
||||
.flatMap(
|
||||
i ->
|
||||
i.siteLocalIpv4s().stream()
|
||||
.map(
|
||||
ip ->
|
||||
new ScoredAddress(
|
||||
ip,
|
||||
scoreInterface(i, ip),
|
||||
i.index())))
|
||||
.max(
|
||||
Comparator.comparingInt(ScoredAddress::score)
|
||||
.thenComparing(
|
||||
Comparator.comparingInt(ScoredAddress::interfaceIndex)
|
||||
.reversed()))
|
||||
.map(ScoredAddress::ip)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static int scoreInterface(NetworkInterfaceInfo iface, String ip) {
|
||||
int score = 0;
|
||||
if (isLikelyPhysicalInterface(iface.name(), iface.displayName())) {
|
||||
score += 100;
|
||||
}
|
||||
if (iface.hasHardwareAddress()) {
|
||||
score += 20;
|
||||
}
|
||||
if (ip.startsWith("192.168.")) {
|
||||
score += 30;
|
||||
} else if (ip.startsWith("10.")) {
|
||||
score += 20;
|
||||
} else {
|
||||
score += 5;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
static boolean isLikelyVirtualInterface(String name, String displayName) {
|
||||
String n = name == null ? "" : name.toLowerCase(Locale.ROOT);
|
||||
String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT);
|
||||
String[] namePrefixes = {
|
||||
"tun", "tap", "utun", "veth", "virbr", "vmnet", "docker", "br-", "wg", "ppp", "awdl",
|
||||
"llw"
|
||||
};
|
||||
for (String prefix : namePrefixes) {
|
||||
if (n.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
String[] displayMarkers = {
|
||||
"vmware",
|
||||
"virtualbox",
|
||||
"virtual box",
|
||||
"vbox",
|
||||
"hyper-v",
|
||||
"hyperv",
|
||||
"vethernet",
|
||||
"windows subsystem for linux",
|
||||
"wsl",
|
||||
"docker",
|
||||
"tap-windows",
|
||||
"tunnel",
|
||||
"vpn",
|
||||
"zerotier",
|
||||
"tailscale",
|
||||
"bluetooth",
|
||||
"teredo",
|
||||
"isatap",
|
||||
"loopback",
|
||||
"pseudo",
|
||||
"virtual"
|
||||
};
|
||||
for (String marker : displayMarkers) {
|
||||
if (d.contains(marker)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isLikelyPhysicalInterface(String name, String displayName) {
|
||||
String n = name == null ? "" : name.toLowerCase(Locale.ROOT);
|
||||
String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT);
|
||||
return n.startsWith("eth")
|
||||
|| n.startsWith("en")
|
||||
|| n.startsWith("wl")
|
||||
|| n.startsWith("em")
|
||||
|| d.contains("ethernet")
|
||||
|| d.contains("wi-fi")
|
||||
|| d.contains("wifi")
|
||||
|| d.contains("wireless");
|
||||
}
|
||||
|
||||
record NetworkInterfaceInfo(
|
||||
String name,
|
||||
String displayName,
|
||||
int index,
|
||||
boolean up,
|
||||
boolean loopback,
|
||||
boolean pointToPoint,
|
||||
boolean virtual,
|
||||
boolean hasHardwareAddress,
|
||||
List<String> siteLocalIpv4s) {}
|
||||
|
||||
private record ScoredAddress(String ip, int score, int interfaceIndex) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.util.GeneralUtils.NetworkInterfaceInfo;
|
||||
|
||||
class GeneralUtilsLocalIpTest {
|
||||
|
||||
private static NetworkInterfaceInfo iface(
|
||||
String name, String displayName, int index, boolean virtual, String... ips) {
|
||||
return new NetworkInterfaceInfo(
|
||||
name, displayName, index, true, false, false, virtual, true, List.of(ips));
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefersPhysicalWifiOverVmwareNatAdapter() {
|
||||
NetworkInterfaceInfo vmware =
|
||||
iface("eth5", "VMware Virtual Ethernet Adapter for VMnet8", 5, false, "172.16.1.1");
|
||||
NetworkInterfaceInfo wifi =
|
||||
iface("wlan0", "Intel(R) Wi-Fi 6 AX201", 12, false, "192.168.1.50");
|
||||
|
||||
assertEquals("192.168.1.50", GeneralUtils.selectBestSiteLocalIp(List.of(vmware, wifi)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void excludesHyperVVethernetAdapter() {
|
||||
NetworkInterfaceInfo hyperv =
|
||||
iface("ethernet_32770", "Hyper-V Virtual Ethernet Adapter", 3, false, "172.28.0.1");
|
||||
NetworkInterfaceInfo ethernet =
|
||||
iface("eth0", "Realtek PCIe GbE Family Controller", 8, false, "192.168.0.20");
|
||||
|
||||
assertEquals("192.168.0.20", GeneralUtils.selectBestSiteLocalIp(List.of(hyperv, ethernet)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void excludesWslAndDockerBridges() {
|
||||
NetworkInterfaceInfo wsl =
|
||||
iface("eth1", "Hyper-V Virtual Ethernet Adapter (WSL)", 70, false, "172.20.0.1");
|
||||
NetworkInterfaceInfo docker = iface("docker0", "docker0", 4, false, "172.17.0.1");
|
||||
NetworkInterfaceInfo lan =
|
||||
iface("eth0", "Intel(R) Ethernet Connection", 2, false, "10.0.0.5");
|
||||
|
||||
assertEquals("10.0.0.5", GeneralUtils.selectBestSiteLocalIp(List.of(wsl, docker, lan)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefers192Over10WhenBothPhysical() {
|
||||
NetworkInterfaceInfo ten = iface("eth0", "Ethernet", 2, false, "10.1.2.3");
|
||||
NetworkInterfaceInfo home = iface("wlan0", "Wi-Fi", 6, false, "192.168.1.10");
|
||||
|
||||
assertEquals("192.168.1.10", GeneralUtils.selectBestSiteLocalIp(List.of(ten, home)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void breaksTiesByLowestInterfaceIndex() {
|
||||
NetworkInterfaceInfo first = iface("eth0", "Ethernet", 2, false, "192.168.1.2");
|
||||
NetworkInterfaceInfo second = iface("eth1", "Ethernet", 9, false, "192.168.1.3");
|
||||
|
||||
assertEquals("192.168.1.2", GeneralUtils.selectBestSiteLocalIp(List.of(second, first)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNullWhenOnlyVirtualOrDownInterfaces() {
|
||||
NetworkInterfaceInfo vbox =
|
||||
iface("vboxnet0", "VirtualBox Host-Only Network", 1, false, "192.168.56.1");
|
||||
NetworkInterfaceInfo flaggedVirtual =
|
||||
new NetworkInterfaceInfo(
|
||||
"eth9",
|
||||
"Ethernet",
|
||||
9,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
List.of("192.168.1.9"));
|
||||
NetworkInterfaceInfo down =
|
||||
new NetworkInterfaceInfo(
|
||||
"eth0",
|
||||
"Ethernet",
|
||||
2,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
List.of("192.168.1.2"));
|
||||
|
||||
assertNull(GeneralUtils.selectBestSiteLocalIp(List.of(vbox, flaggedVirtual, down)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isLikelyVirtualInterfaceFlagsKnownAdaptersButNotRealNics() {
|
||||
assertTrue(
|
||||
GeneralUtils.isLikelyVirtualInterface(
|
||||
"vEthernet", "Hyper-V Virtual Ethernet Adapter"));
|
||||
assertTrue(GeneralUtils.isLikelyVirtualInterface("docker0", "docker0"));
|
||||
assertTrue(
|
||||
GeneralUtils.isLikelyVirtualInterface("eth0", "VMware Virtual Ethernet Adapter"));
|
||||
assertTrue(GeneralUtils.isLikelyVirtualInterface("tun0", "WireGuard tunnel"));
|
||||
|
||||
assertFalse(GeneralUtils.isLikelyVirtualInterface("wlan0", "Intel(R) Wi-Fi 6 AX201"));
|
||||
assertFalse(
|
||||
GeneralUtils.isLikelyVirtualInterface(
|
||||
"eth0", "Realtek PCIe GbE Family Controller"));
|
||||
}
|
||||
}
|
||||
@@ -330,6 +330,12 @@ tasks.register('cleanFrontendAssets', Delete) {
|
||||
group = 'frontend'
|
||||
description = 'Remove previously generated frontend assets from static resources'
|
||||
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
|
||||
// Prerendered per-route SPA pages (e.g. compress.html) carry per-tool OG tags and are
|
||||
// copied from the frontend build. Remove stale ones so renamed/removed tools don't linger.
|
||||
// api-landing.html and mobile-upload.html are real backend source files, not generated artifacts.
|
||||
delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html'])
|
||||
// Nested prerendered route pages (e.g. settings/people.html)
|
||||
delete new File(resourcesStaticDir, 'settings')
|
||||
}
|
||||
|
||||
tasks.register('copyApiLandingPage', Copy) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.service.PdfMetricsService;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class PdfMetricsInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final PdfMetricsService pdfMetricsService;
|
||||
|
||||
@Override
|
||||
public void afterCompletion(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler,
|
||||
Exception ex) {
|
||||
try {
|
||||
if (!pdfMetricsService.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (!"POST".equalsIgnoreCase(request.getMethod()) || response.getStatus() >= 400) {
|
||||
return;
|
||||
}
|
||||
String path = request.getServletPath();
|
||||
if (path == null || path.isBlank()) {
|
||||
path = request.getRequestURI();
|
||||
}
|
||||
if (path == null || !path.contains("/api/v1/")) {
|
||||
return;
|
||||
}
|
||||
if (!(request instanceof MultipartHttpServletRequest multipart)) {
|
||||
return;
|
||||
}
|
||||
if (isFromEditor(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
int fileCount = 0;
|
||||
for (List<MultipartFile> bucket : multipart.getMultiFileMap().values()) {
|
||||
fileCount += bucket.size();
|
||||
}
|
||||
if (fileCount == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
pdfMetricsService.recordOperation(fileCount);
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to record PDF metrics", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Editor traffic carries X-Browser-Id, or (if a proxy strips it) a logged-in user's JWT.
|
||||
// JWTs start "eyJ" and have two dots; API keys do not, so they still count as API.
|
||||
private boolean isFromEditor(HttpServletRequest request) {
|
||||
String browserId = request.getHeader("X-Browser-Id");
|
||||
if (browserId != null && !browserId.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
String auth = request.getHeader("Authorization");
|
||||
if (auth == null || !auth.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||
return false;
|
||||
}
|
||||
String token = auth.substring(7).trim();
|
||||
return token.startsWith("eyJ") && token.chars().filter(c -> c == '.').count() == 2;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final EndpointInterceptor endpointInterceptor;
|
||||
private final PdfMetricsInterceptor pdfMetricsInterceptor;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebMvcConfig.class);
|
||||
@@ -35,6 +36,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(endpointInterceptor);
|
||||
registry.addInterceptor(pdfMetricsInterceptor);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+41
@@ -42,6 +42,8 @@ public class ReactRoutingController {
|
||||
private boolean loggedMissingIndex = false;
|
||||
private String cachedSaasLandingHtml;
|
||||
private boolean saasLandingExists = false;
|
||||
private String cachedMobileUploadHtml;
|
||||
private boolean mobileUploadHtmlExists = false;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
@@ -64,6 +66,12 @@ public class ReactRoutingController {
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop (Tauri) serves the SPA from its bundled webview, so a phone scanning the QR can't
|
||||
// load the React /mobile-scanner route from the local backend. Cache the self-contained
|
||||
// static upload page to serve at that route in desktop mode instead.
|
||||
this.cachedMobileUploadHtml = readStaticHtml("mobile-upload.html");
|
||||
this.mobileUploadHtmlExists = this.cachedMobileUploadHtml != null;
|
||||
|
||||
// Check for external index.html first (customFiles/static/)
|
||||
Path externalIndexPath = Path.of(InstallationPathConfig.getStaticPath(), "index.html");
|
||||
log.debug("Checking for custom index.html at: {}", externalIndexPath);
|
||||
@@ -144,6 +152,28 @@ public class ReactRoutingController {
|
||||
return new ClassPathResource("static/index.html");
|
||||
}
|
||||
|
||||
private String readStaticHtml(String filename) {
|
||||
try {
|
||||
Path external = Path.of(InstallationPathConfig.getStaticPath(), filename);
|
||||
if (Files.exists(external) && Files.isReadable(external)) {
|
||||
return Files.readString(external, StandardCharsets.UTF_8);
|
||||
}
|
||||
ClassPathResource resource = new ClassPathResource("static/" + filename);
|
||||
if (resource.exists()) {
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to read static HTML {}", filename, ex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isDesktopMode() {
|
||||
return Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"));
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
value = {"/", "/index.html"},
|
||||
produces = MediaType.TEXT_HTML_VALUE)
|
||||
@@ -191,6 +221,17 @@ public class ReactRoutingController {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/mobile-scanner", produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> serveMobileScanner(HttpServletRequest request) {
|
||||
if (isDesktopMode() && mobileUploadHtmlExists) {
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.noCache().mustRevalidate())
|
||||
.contentType(MediaType.TEXT_HTML)
|
||||
.body(cachedMobileUploadHtml);
|
||||
}
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/auth/callback/tauri", produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> serveTauriAuthCallback(HttpServletRequest request) {
|
||||
// cachedCallbackHtml is always initialized in @PostConstruct
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.PostHogService;
|
||||
|
||||
@Service
|
||||
public class PdfMetricsService {
|
||||
|
||||
private final PostHogService postHogService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
private final AtomicLong operations = new AtomicLong();
|
||||
private final AtomicLong pdfs = new AtomicLong();
|
||||
private long lastOperations;
|
||||
private long lastPdfs;
|
||||
|
||||
public PdfMetricsService(
|
||||
PostHogService postHogService, ApplicationProperties applicationProperties) {
|
||||
this.postHogService = postHogService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return applicationProperties.getSystem().isPosthogEnabled();
|
||||
}
|
||||
|
||||
public void recordOperation(int pdfCount) {
|
||||
if (!isEnabled()) {
|
||||
return;
|
||||
}
|
||||
operations.incrementAndGet();
|
||||
if (pdfCount > 0) {
|
||||
pdfs.addAndGet(pdfCount);
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 7200000)
|
||||
public void flushMetrics() {
|
||||
if (!isEnabled()) {
|
||||
return;
|
||||
}
|
||||
long curOps = operations.get();
|
||||
long curPdfs = pdfs.get();
|
||||
long opsDelta = curOps - lastOperations;
|
||||
long pdfsDelta = curPdfs - lastPdfs;
|
||||
if (opsDelta <= 0 && pdfsDelta <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> props = new HashMap<>();
|
||||
props.put("source", "api");
|
||||
props.put("operations", opsDelta);
|
||||
props.put("pdfs", pdfsDelta);
|
||||
postHogService.captureEvent("pdf_operation_metrics", props);
|
||||
|
||||
lastOperations = curOps;
|
||||
lastPdfs = curPdfs;
|
||||
}
|
||||
}
|
||||
@@ -2215,6 +2215,13 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-security-oauth2-resource-server",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "4.0.6",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-servlet",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
@@ -2320,6 +2327,13 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-oauth2-resource-server",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "4.0.6",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-security",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
@@ -2438,6 +2452,13 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.security:spring-security-oauth2-resource-server",
|
||||
"moduleUrl": "https://spring.io/projects/spring-security",
|
||||
"moduleVersion": "7.0.5",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.security:spring-security-saml2-service-provider",
|
||||
"moduleUrl": "https://spring.io/projects/spring-security",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,107 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import stirling.software.SPDF.service.PdfMetricsService;
|
||||
|
||||
class PdfMetricsInterceptorTest {
|
||||
|
||||
private PdfMetricsService service;
|
||||
private PdfMetricsInterceptor interceptor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = mock(PdfMetricsService.class);
|
||||
when(service.isEnabled()).thenReturn(true);
|
||||
interceptor = new PdfMetricsInterceptor(service);
|
||||
}
|
||||
|
||||
private MultipartHttpServletRequest editRequest(int fileParts, String... headers) {
|
||||
MultipartHttpServletRequest request = mock(MultipartHttpServletRequest.class);
|
||||
when(request.getMethod()).thenReturn("POST");
|
||||
when(request.getServletPath()).thenReturn("/api/v1/general/rotate-pdf");
|
||||
for (int i = 0; i + 1 < headers.length; i += 2) {
|
||||
when(request.getHeader(headers[i])).thenReturn(headers[i + 1]);
|
||||
}
|
||||
MultiValueMap<String, MultipartFile> files = new LinkedMultiValueMap<>();
|
||||
for (int i = 0; i < fileParts; i++) {
|
||||
files.add("fileInput", mock(MultipartFile.class));
|
||||
}
|
||||
when(request.getMultiFileMap()).thenReturn(files);
|
||||
return request;
|
||||
}
|
||||
|
||||
private HttpServletResponse response(int status, String contentType) {
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
when(response.getStatus()).thenReturn(status);
|
||||
when(response.getContentType()).thenReturn(contentType);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiRequestIsCounted() {
|
||||
interceptor.afterCompletion(editRequest(1), response(200, "application/pdf"), null, null);
|
||||
verify(service).recordOperation(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countsEveryFilePartUnderOneFieldName() {
|
||||
interceptor.afterCompletion(editRequest(3), response(200, "application/pdf"), null, null);
|
||||
verify(service).recordOperation(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countsRegardlessOfResponseType() {
|
||||
interceptor.afterCompletion(editRequest(1), response(200, "application/json"), null, null);
|
||||
verify(service).recordOperation(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void editorRequestWithBrowserIdIsNotCounted() {
|
||||
interceptor.afterCompletion(
|
||||
editRequest(1, "X-Browser-Id", "abc-123"),
|
||||
response(200, "application/pdf"),
|
||||
null,
|
||||
null);
|
||||
verify(service, never()).recordOperation(anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editorJwtWithoutBrowserIdIsNotCounted() {
|
||||
interceptor.afterCompletion(
|
||||
editRequest(1, "Authorization", "Bearer eyJhbG.eyJzdWI.sig"),
|
||||
response(200, "application/pdf"),
|
||||
null,
|
||||
null);
|
||||
verify(service, never()).recordOperation(anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bearerApiKeyIsCounted() {
|
||||
interceptor.afterCompletion(
|
||||
editRequest(1, "Authorization", "Bearer sk-not-a-jwt-key"),
|
||||
response(200, "application/pdf"),
|
||||
null,
|
||||
null);
|
||||
verify(service).recordOperation(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void errorResponseIsNotCounted() {
|
||||
interceptor.afterCompletion(editRequest(1), response(500, "application/pdf"), null, null);
|
||||
verify(service, never()).recordOperation(anyInt());
|
||||
}
|
||||
}
|
||||
+32
@@ -95,6 +95,38 @@ class ReactRoutingControllerTest {
|
||||
assertTrue(body.contains("Stirling PDF"));
|
||||
}
|
||||
|
||||
// --- mobile scanner route ---
|
||||
|
||||
@Test
|
||||
void serveMobileScanner_webMode_servesSpaNotUploadPage() {
|
||||
controller.init();
|
||||
|
||||
ResponseEntity<String> response = controller.serveMobileScanner(request);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
String body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertFalse(body.contains("Take Photo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void serveMobileScanner_desktopMode_servesStaticUploadPage() {
|
||||
controller.init();
|
||||
System.setProperty("STIRLING_PDF_TAURI_MODE", "true");
|
||||
try {
|
||||
ResponseEntity<String> response = controller.serveMobileScanner(request);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals(MediaType.TEXT_HTML, response.getHeaders().getContentType());
|
||||
String body = response.getBody();
|
||||
assertNotNull(body);
|
||||
assertTrue(body.contains("Mobile Upload"));
|
||||
assertTrue(body.contains("Take Photo"));
|
||||
} finally {
|
||||
System.clearProperty("STIRLING_PDF_TAURI_MODE");
|
||||
}
|
||||
}
|
||||
|
||||
// --- tauri auth callback ---
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.PostHogService;
|
||||
|
||||
class PdfMetricsServiceTest {
|
||||
|
||||
private PostHogService postHogService;
|
||||
private ApplicationProperties applicationProperties;
|
||||
private PdfMetricsService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
postHogService = mock(PostHogService.class);
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getSystem().setEnableAnalytics(true);
|
||||
service = new PdfMetricsService(postHogService, applicationProperties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void flushesOperationAndPdfCounts() {
|
||||
service.recordOperation(1);
|
||||
service.recordOperation(2);
|
||||
|
||||
service.flushMetrics();
|
||||
|
||||
Map<String, Object> event = captureEvent();
|
||||
assertEquals("api", event.get("source"));
|
||||
assertEquals(2L, event.get("operations"));
|
||||
assertEquals(3L, event.get("pdfs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsOnlyDeltasBetweenFlushes() {
|
||||
service.recordOperation(1);
|
||||
service.flushMetrics();
|
||||
reset(postHogService);
|
||||
|
||||
service.flushMetrics();
|
||||
verify(postHogService, never()).captureEvent(eq("pdf_operation_metrics"), anyMap());
|
||||
|
||||
service.recordOperation(2);
|
||||
service.flushMetrics();
|
||||
|
||||
Map<String, Object> event = captureEvent();
|
||||
assertEquals(1L, event.get("operations"));
|
||||
assertEquals(2L, event.get("pdfs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNothingWhenAnalyticsDisabled() {
|
||||
applicationProperties.getSystem().setEnableAnalytics(false);
|
||||
|
||||
service.recordOperation(1);
|
||||
service.flushMetrics();
|
||||
|
||||
verify(postHogService, never()).captureEvent(eq("pdf_operation_metrics"), anyMap());
|
||||
}
|
||||
|
||||
private Map<String, Object> captureEvent() {
|
||||
ArgumentCaptor<Map<String, Object>> captor = ArgumentCaptor.forClass(Map.class);
|
||||
verify(postHogService).captureEvent(eq("pdf_operation_metrics"), captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
repositories {
|
||||
maven { url = "https://build.shibboleth.net/maven/releases" }
|
||||
maven { url = "https://repository.jboss.org/" }
|
||||
maven { url = "https://build.shibboleth.net/maven/releases" }
|
||||
}
|
||||
|
||||
ext {
|
||||
|
||||
+35
-5
@@ -1,6 +1,10 @@
|
||||
package stirling.software.proprietary.config;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -61,17 +65,43 @@ public class CustomAuditEventRepository implements AuditEventRepository {
|
||||
|
||||
PersistentAuditEvent ent =
|
||||
PersistentAuditEvent.builder()
|
||||
.principal(ev.getPrincipal())
|
||||
.principal(safePrincipal(ev.getPrincipal()))
|
||||
.type(ev.getType())
|
||||
.data(auditEventData)
|
||||
.timestamp(ev.getTimestamp())
|
||||
.build();
|
||||
repo.save(ent);
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Failed to persist audit event (fail-open); principal={}",
|
||||
ev.getPrincipal(),
|
||||
e);
|
||||
log.error("Failed to persist audit event (fail-open); type={}", ev.getType(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Width of the {@code principal} column; longer values are hashed so the insert can't fail. */
|
||||
private static final int PRINCIPAL_MAX_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* Hash JWT-shaped or over-long principals so the insert fits the column and stores no secret.
|
||||
*/
|
||||
static String safePrincipal(String principal) {
|
||||
if (principal == null || principal.isBlank()) {
|
||||
return "anonymous";
|
||||
}
|
||||
// Hash JWTs ("eyJ...") and any over-long value rather than store verbatim.
|
||||
if (principal.startsWith("eyJ") || principal.length() > PRINCIPAL_MAX_LENGTH) {
|
||||
return "token:" + sha256Prefix(principal);
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
|
||||
/** First 8 bytes of SHA-256 as hex: stable, one-way, collision-safe enough. */
|
||||
private static String sha256Prefix(String value) {
|
||||
try {
|
||||
byte[] digest =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest, 0, 8);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
return "unhashable";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -43,8 +43,8 @@ public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
|
||||
return OAuth2TokenValidatorResult.failure(
|
||||
new OAuth2Error(
|
||||
"invalid_token",
|
||||
"MCP server has no resource id configured; rejecting all tokens"
|
||||
+ " until mcp.auth.resource-id is set.",
|
||||
"MCP audience binding is not configured; rejecting all tokens until"
|
||||
+ " mcp.auth.resource-id or mcp.auth.accepted-audiences is set.",
|
||||
null));
|
||||
}
|
||||
List<String> aud = token.getAudience();
|
||||
|
||||
+43
-5
@@ -4,15 +4,21 @@ import java.io.IOException;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Emits 401 + {@code WWW-Authenticate: Bearer resource_metadata="..."} (RFC 9728), preferring
|
||||
* X-Forwarded-* headers to build the public-facing metadata URL.
|
||||
* Emits 401 + {@code WWW-Authenticate: Bearer resource_metadata="..."} (RFC 9728) from
|
||||
* X-Forwarded-* headers. A rejected token also logs the OAuth2 reason and echoes it as {@code
|
||||
* error_description}.
|
||||
*/
|
||||
@Slf4j
|
||||
public class McpAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
|
||||
private final String metadataPath;
|
||||
@@ -28,15 +34,47 @@ public class McpAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
HttpServletResponse response,
|
||||
AuthenticationException authException)
|
||||
throws IOException {
|
||||
// Tokenless 401 is the normal discovery handshake; only a rejected token is a real failure.
|
||||
boolean tokenPresented = request.getHeader("Authorization") != null;
|
||||
String reason = rejectionReason(authException);
|
||||
if (tokenPresented) {
|
||||
log.warn("MCP rejected bearer token: {}", reason != null ? reason : "invalid_token");
|
||||
} else {
|
||||
log.debug("MCP 401: no bearer token; returning protected-resource metadata pointer");
|
||||
}
|
||||
|
||||
String scheme = firstForwarded(request, "X-Forwarded-Proto", request.getScheme());
|
||||
String authority = forwardedHost(request, scheme);
|
||||
String metadataUrl = scheme + "://" + authority + metadataPath;
|
||||
response.setHeader(
|
||||
"WWW-Authenticate",
|
||||
"Bearer error=\"invalid_token\", resource_metadata=\"" + metadataUrl + "\"");
|
||||
|
||||
StringBuilder header = new StringBuilder("Bearer error=\"invalid_token\"");
|
||||
if (tokenPresented && reason != null) {
|
||||
header.append(", error_description=\"").append(reason).append('"');
|
||||
}
|
||||
header.append(", resource_metadata=\"").append(metadataUrl).append('"');
|
||||
response.setHeader("WWW-Authenticate", header.toString());
|
||||
response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized");
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth2 error as {@code "code - description"}, sanitized for a header/log line; null if none.
|
||||
*/
|
||||
private static String rejectionReason(AuthenticationException ex) {
|
||||
if (!(ex instanceof OAuth2AuthenticationException oae)) {
|
||||
return null;
|
||||
}
|
||||
OAuth2Error error = oae.getError();
|
||||
if (error == null) {
|
||||
return null;
|
||||
}
|
||||
String description = error.getDescription();
|
||||
String combined =
|
||||
(description == null || description.isBlank())
|
||||
? error.getErrorCode()
|
||||
: error.getErrorCode() + " - " + description;
|
||||
return combined == null ? null : combined.replaceAll("[\\r\\n\"]", " ").trim();
|
||||
}
|
||||
|
||||
/** host[:port] from forwarded headers when present, else the servlet host/port. */
|
||||
private static String forwardedHost(HttpServletRequest request, String scheme) {
|
||||
String host = firstForwarded(request, "X-Forwarded-Host", null);
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Startup sanity-checks for MCP config; {@link McpSecurityConfig} logs the findings at boot so a
|
||||
* misconfigured /mcp endpoint shows up in the logs instead of as a later rejected-token 401.
|
||||
*/
|
||||
public final class McpConfigValidator {
|
||||
|
||||
public enum Severity {
|
||||
WARN,
|
||||
INFO
|
||||
}
|
||||
|
||||
public record Finding(Severity severity, String message) {}
|
||||
|
||||
private McpConfigValidator() {}
|
||||
|
||||
/** Inspect the resolved MCP config and return ordered findings (most actionable first). */
|
||||
public static List<Finding> validate(ApplicationProperties.Mcp mcp) {
|
||||
List<Finding> findings = new ArrayList<>();
|
||||
ApplicationProperties.Mcp.Auth auth = mcp.getAuth();
|
||||
|
||||
if ("apikey".equalsIgnoreCase(auth.getMode())) {
|
||||
findings.add(
|
||||
info(
|
||||
"auth mode = apikey - clients send a Stirling API key via X-API-KEY (or"
|
||||
+ " Authorization: Bearer <key>); no external IdP needed. The key"
|
||||
+ " must belong to a provisioned, enabled account (Account -> API"
|
||||
+ " Keys)."));
|
||||
return findings;
|
||||
}
|
||||
|
||||
// Anything that isn't exactly "apikey" runs the OAuth chain (mirrors isApiKeyMode()).
|
||||
String mode = auth.getMode();
|
||||
if (mode != null && !mode.isBlank() && !"oauth".equalsIgnoreCase(mode.trim())) {
|
||||
findings.add(
|
||||
warn(
|
||||
"mcp.auth.mode='"
|
||||
+ mode
|
||||
+ "' is not recognized (expected 'oauth' or 'apikey'); it falls"
|
||||
+ " back to the OAuth chain, which rejects every token unless"
|
||||
+ " issuer-uri and resource-id are set. A near-miss like"
|
||||
+ " 'api-key' is NOT treated as API-key mode."));
|
||||
}
|
||||
findings.add(info("auth mode = oauth - running as an OAuth2 resource server for /mcp."));
|
||||
|
||||
if (isBlank(auth.getIssuerUri())) {
|
||||
findings.add(
|
||||
warn(
|
||||
"mcp.auth.issuer-uri is not set: the JWT decoder fails closed and rejects"
|
||||
+ " every token. Set it to your IdP issuer that publishes"
|
||||
+ " /.well-known/openid-configuration (e.g."
|
||||
+ " https://login.microsoftonline.com/<tenant>/v2.0)."));
|
||||
} else if (!looksLikeUrl(auth.getIssuerUri())) {
|
||||
findings.add(
|
||||
warn(
|
||||
"mcp.auth.issuer-uri='"
|
||||
+ auth.getIssuerUri()
|
||||
+ "' does not look like an http(s) URL."));
|
||||
}
|
||||
|
||||
boolean hasResourceId = !isBlank(auth.getResourceId());
|
||||
boolean hasAcceptedAudiences =
|
||||
auth.getAcceptedAudiences().stream().anyMatch(a -> !isBlank(a));
|
||||
|
||||
if (!hasResourceId && !hasAcceptedAudiences) {
|
||||
findings.add(
|
||||
warn(
|
||||
"neither mcp.auth.resource-id nor mcp.auth.accepted-audiences is set: the"
|
||||
+ " audience validator fails closed and rejects every token (RFC"
|
||||
+ " 8707). Set resource-id to this server's public /mcp URL, or"
|
||||
+ " accepted-audiences to the audience your IdP actually mints."));
|
||||
} else {
|
||||
if (hasResourceId && !looksLikeUrl(auth.getResourceId())) {
|
||||
findings.add(
|
||||
warn(
|
||||
"mcp.auth.resource-id='"
|
||||
+ auth.getResourceId()
|
||||
+ "' is not an http(s) URL: the token aud must match it"
|
||||
+ " exactly (scheme, host and port included)."));
|
||||
} else if (hasResourceId && !auth.getResourceId().endsWith("/mcp")) {
|
||||
findings.add(
|
||||
warn(
|
||||
"mcp.auth.resource-id='"
|
||||
+ auth.getResourceId()
|
||||
+ "' does not end in /mcp: it must match the public URL"
|
||||
+ " clients call and the audience your IdP puts in the"
|
||||
+ " token."));
|
||||
}
|
||||
if (hasAcceptedAudiences) {
|
||||
findings.add(
|
||||
info(
|
||||
"mcp.auth.accepted-audiences="
|
||||
+ auth.getAcceptedAudiences()
|
||||
+ " - tokens whose aud matches any of these are accepted, the"
|
||||
+ " escape hatch for IdPs that can't mint a resource-specific"
|
||||
+ " audience (e.g. an Entra ID app id, or Supabase's"
|
||||
+ " aud=authenticated)."));
|
||||
} else {
|
||||
findings.add(
|
||||
info(
|
||||
"audience binding is strict (token aud must equal"
|
||||
+ " mcp.auth.resource-id). If your IdP can't mint that - e.g."
|
||||
+ " Entra ID issues aud=<client-id> - set"
|
||||
+ " mcp.auth.accepted-audiences to the audience it actually"
|
||||
+ " emits."));
|
||||
}
|
||||
}
|
||||
|
||||
if (isBlank(auth.getJwksUri())) {
|
||||
findings.add(
|
||||
info(
|
||||
"mcp.auth.jwks-uri not set - signing keys are auto-discovered from the"
|
||||
+ " issuer's OpenID configuration."));
|
||||
}
|
||||
|
||||
if ("sub".equalsIgnoreCase(auth.getUsernameClaim()) && auth.isRequireExistingAccount()) {
|
||||
findings.add(
|
||||
warn(
|
||||
"mcp.auth.username-claim='sub' with require-existing-account=true: many"
|
||||
+ " IdPs (e.g. Entra ID, Google) set 'sub' to an opaque id that won't"
|
||||
+ " match a Stirling username. Set mcp.auth.username-claim to 'email'"
|
||||
+ " or 'preferred_username', or provision accounts keyed by sub."));
|
||||
}
|
||||
|
||||
if (!auth.isRequireExistingAccount()) {
|
||||
findings.add(
|
||||
warn(
|
||||
"mcp.auth.require-existing-account=false: any token your IdP signs can"
|
||||
+ " invoke MCP tools even if its subject has no Stirling account. Set"
|
||||
+ " it true unless you intend open access for every IdP-valid"
|
||||
+ " token."));
|
||||
}
|
||||
|
||||
if (mcp.isScopesEnabled()) {
|
||||
findings.add(
|
||||
info(
|
||||
"mcp.scopes-enabled=true - the IdP must mint 'mcp.tools.read' and"
|
||||
+ " 'mcp.tools.write' scopes or clients are rejected; set"
|
||||
+ " mcp.scopes-enabled=false if it can only issue coarse tokens."));
|
||||
}
|
||||
|
||||
List<String> allowed = mcp.getAllowedOperations();
|
||||
List<String> blocked = mcp.getBlockedOperations();
|
||||
if (allowed != null && !allowed.isEmpty()) {
|
||||
findings.add(
|
||||
info(
|
||||
"mcp.allowed-operations is a strict allow-list of "
|
||||
+ allowed.size()
|
||||
+ " operation(s); every other tool is hidden, so a wrong or"
|
||||
+ " typo'd id silently exposes nothing."));
|
||||
List<String> shadowed =
|
||||
blocked == null
|
||||
? List.of()
|
||||
: allowed.stream().filter(blocked::contains).toList();
|
||||
if (!shadowed.isEmpty()) {
|
||||
findings.add(
|
||||
warn(
|
||||
"mcp operation(s) "
|
||||
+ shadowed
|
||||
+ " are in both allowed-operations and blocked-operations;"
|
||||
+ " blocked wins, so they are hidden."));
|
||||
}
|
||||
}
|
||||
|
||||
if (findings.stream().noneMatch(f -> f.severity() == Severity.WARN)) {
|
||||
findings.add(info("OAuth settings look complete."));
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private static boolean looksLikeUrl(String value) {
|
||||
String lower = value.toLowerCase(Locale.ROOT);
|
||||
return lower.startsWith("http://") || lower.startsWith("https://");
|
||||
}
|
||||
|
||||
private static Finding warn(String message) {
|
||||
return new Finding(Severity.WARN, message);
|
||||
}
|
||||
|
||||
private static Finding info(String message) {
|
||||
return new Finding(Severity.INFO, message);
|
||||
}
|
||||
}
|
||||
+8
-18
@@ -77,24 +77,14 @@ public class McpSecurityConfig {
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void warnIfMisconfigured() {
|
||||
ApplicationProperties.Mcp mcp = applicationProperties.getMcp();
|
||||
if (isApiKeyMode()) {
|
||||
log.info(
|
||||
"MCP auth mode = apikey: clients authenticate with a Stirling per-user API key"
|
||||
+ " (X-API-KEY header). No OAuth issuer required.");
|
||||
} else {
|
||||
if (mcp.getAuth().getIssuerUri().isBlank()) {
|
||||
log.warn(
|
||||
"MCP enabled but mcp.auth.issuer-uri is blank - JWT decoder will reject"
|
||||
+ " every token (fail-closed). Set mcp.auth.issuer-uri and"
|
||||
+ " mcp.auth.resource-id before exposing /mcp to clients.");
|
||||
}
|
||||
if (mcp.getAuth().getResourceId().isBlank()) {
|
||||
log.warn(
|
||||
"MCP enabled but mcp.auth.resource-id is blank - audience validator will"
|
||||
+ " reject every token. Set this to the public URL of the MCP"
|
||||
+ " endpoint (RFC 8707).");
|
||||
void validateConfigOnStartup() {
|
||||
log.info("MCP server enabled - validating configuration:");
|
||||
for (McpConfigValidator.Finding finding :
|
||||
McpConfigValidator.validate(applicationProperties.getMcp())) {
|
||||
if (finding.severity() == McpConfigValidator.Severity.WARN) {
|
||||
log.warn("MCP config: {}", finding.message());
|
||||
} else {
|
||||
log.info("MCP config: {}", finding.message());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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";
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+33
@@ -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)
|
||||
|
||||
+20
-3
@@ -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);
|
||||
|
||||
+4
@@ -116,6 +116,10 @@ public class PolicyExecutor {
|
||||
ToolResult r = callEndpoint(step, inputFiles, supportingFiles);
|
||||
files.addAll(r.files());
|
||||
report = r.report();
|
||||
} else if (inputFiles.isEmpty()) {
|
||||
ToolResult r = callEndpoint(step, List.of(), supportingFiles);
|
||||
files.addAll(r.files());
|
||||
report = r.report();
|
||||
} else {
|
||||
for (Resource file : inputFiles) {
|
||||
ToolResult r = callEndpoint(step, List.of(file), supportingFiles);
|
||||
|
||||
+2
@@ -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<>();
|
||||
|
||||
+2
@@ -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
@@ -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;
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+6
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+7
-2
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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";
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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";
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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";
|
||||
|
||||
+2
-1
@@ -202,7 +202,8 @@ public class SecurityConfiguration {
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN"));
|
||||
"X-XSRF-TOKEN",
|
||||
"X-Browser-Id"));
|
||||
|
||||
cfg.setExposedHeaders(
|
||||
List.of(
|
||||
|
||||
+25
-8
@@ -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(),
|
||||
|
||||
-9
@@ -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")
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package stirling.software.proprietary.config;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CustomAuditEventRepositoryTest {
|
||||
|
||||
@Test
|
||||
void shortPrincipalPassesThroughUnchanged() {
|
||||
assertEquals(
|
||||
"alice@example.com", CustomAuditEventRepository.safePrincipal("alice@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankOrNullPrincipalBecomesAnonymous() {
|
||||
assertEquals("anonymous", CustomAuditEventRepository.safePrincipal(null));
|
||||
assertEquals("anonymous", CustomAuditEventRepository.safePrincipal(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenShapedPrincipalIsHashedNotStoredVerbatim() {
|
||||
String jwt = "eyJhbGciOiJSUzI1NiJ9." + "x".repeat(1400);
|
||||
|
||||
String safe = CustomAuditEventRepository.safePrincipal(jwt);
|
||||
|
||||
assertNotEquals(jwt, safe);
|
||||
assertFalse(safe.contains(jwt), "raw token must not be stored");
|
||||
assertTrue(safe.startsWith("token:"));
|
||||
assertTrue(safe.length() <= 255, "must fit the principal column");
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinctTokensStayDistinguishable() {
|
||||
String a = CustomAuditEventRepository.safePrincipal("eyJ" + "a".repeat(400));
|
||||
String b = CustomAuditEventRepository.safePrincipal("eyJ" + "b".repeat(400));
|
||||
|
||||
assertNotEquals(a, b, "different tokens must map to different audit principals");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameTokenHashesStably() {
|
||||
String token = "eyJ" + "c".repeat(400);
|
||||
|
||||
assertEquals(
|
||||
CustomAuditEventRepository.safePrincipal(token),
|
||||
CustomAuditEventRepository.safePrincipal(token));
|
||||
}
|
||||
}
|
||||
+28
@@ -11,6 +11,8 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
@@ -58,4 +60,30 @@ class McpAuthenticationEntryPointTest {
|
||||
verify(resp).setHeader(eq("WWW-Authenticate"), header.capture());
|
||||
assertTrue(header.getValue().contains("http://localhost:8080" + META), header.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void surfacesRejectionReasonWhenTokenPresented() throws Exception {
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
when(req.getScheme()).thenReturn("https");
|
||||
when(req.getServerName()).thenReturn("mcp.example.com");
|
||||
when(req.getServerPort()).thenReturn(443);
|
||||
when(req.getHeader("Authorization")).thenReturn("Bearer bad.token");
|
||||
HttpServletResponse resp = mock(HttpServletResponse.class);
|
||||
|
||||
OAuth2Error error =
|
||||
new OAuth2Error(
|
||||
"invalid_token",
|
||||
"Token audience does not include this server's resource id"
|
||||
+ " (https://mcp.example.com/mcp).",
|
||||
null);
|
||||
|
||||
entryPoint.commence(req, resp, new OAuth2AuthenticationException(error));
|
||||
|
||||
ArgumentCaptor<String> header = ArgumentCaptor.forClass(String.class);
|
||||
verify(resp).setHeader(eq("WWW-Authenticate"), header.capture());
|
||||
String www = header.getValue();
|
||||
assertTrue(
|
||||
www.contains("error_description=\"invalid_token - Token audience does not include"),
|
||||
"must surface the rejection reason, got: " + www);
|
||||
}
|
||||
}
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
class McpConfigValidatorTest {
|
||||
|
||||
private static ApplicationProperties.Mcp newMcp() {
|
||||
return new ApplicationProperties.Mcp();
|
||||
}
|
||||
|
||||
private static boolean hasWarn(List<McpConfigValidator.Finding> findings, String needle) {
|
||||
return findings.stream()
|
||||
.anyMatch(
|
||||
f ->
|
||||
f.severity() == McpConfigValidator.Severity.WARN
|
||||
&& f.message().contains(needle));
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiKeyModeSkipsOAuthChecks() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setMode("apikey");
|
||||
|
||||
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(mcp);
|
||||
|
||||
assertEquals(1, findings.size());
|
||||
assertEquals(McpConfigValidator.Severity.INFO, findings.get(0).severity());
|
||||
assertTrue(findings.get(0).message().contains("apikey"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankIssuerAndResourceProduceWarnings() {
|
||||
// Defaults: oauth mode, blank issuer-uri and resource-id.
|
||||
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(newMcp());
|
||||
|
||||
assertTrue(hasWarn(findings, "issuer-uri"), "blank issuer must warn");
|
||||
assertTrue(hasWarn(findings, "resource-id"), "blank resource-id must warn");
|
||||
}
|
||||
|
||||
@Test
|
||||
void subClaimWithRequireAccountWarns() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setIssuerUri("https://issuer.example.com");
|
||||
mcp.getAuth().setResourceId("https://host.example.com/mcp");
|
||||
// Defaults username-claim=sub, require-existing-account=true.
|
||||
|
||||
assertTrue(hasWarn(McpConfigValidator.validate(mcp), "username-claim='sub'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeConfigReportsReadyWithNoWarnings() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setIssuerUri("https://issuer.example.com");
|
||||
mcp.getAuth().setResourceId("https://host.example.com/mcp");
|
||||
mcp.getAuth().setUsernameClaim("email");
|
||||
mcp.setScopesEnabled(false);
|
||||
|
||||
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(mcp);
|
||||
|
||||
assertTrue(
|
||||
findings.stream().noneMatch(f -> f.severity() == McpConfigValidator.Severity.WARN),
|
||||
"complete config must have no warnings");
|
||||
assertTrue(findings.stream().anyMatch(f -> f.message().contains("look complete")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptedAudiencesCoverBlankResourceId() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setIssuerUri("https://issuer.example.com");
|
||||
mcp.getAuth().setResourceId("");
|
||||
mcp.getAuth().setAcceptedAudiences(List.of("authenticated"));
|
||||
mcp.getAuth().setUsernameClaim("email");
|
||||
mcp.setScopesEnabled(false);
|
||||
|
||||
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(mcp);
|
||||
|
||||
assertFalse(
|
||||
hasWarn(findings, "fails closed"),
|
||||
"accepted-audiences must satisfy audience binding without a resource id");
|
||||
assertTrue(
|
||||
findings.stream().anyMatch(f -> f.message().contains("accepted-audiences=")),
|
||||
"configured accepted-audiences should be surfaced");
|
||||
}
|
||||
|
||||
@Test
|
||||
void strictAudienceHintsAtAcceptedAudiencesEscapeHatch() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setIssuerUri("https://issuer.example.com");
|
||||
mcp.getAuth().setResourceId("https://host.example.com/mcp");
|
||||
mcp.getAuth().setUsernameClaim("email");
|
||||
mcp.setScopesEnabled(false);
|
||||
|
||||
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(mcp);
|
||||
|
||||
assertTrue(
|
||||
findings.stream().anyMatch(f -> f.message().contains("accepted-audiences")),
|
||||
"should point coarse-audience IdPs at accepted-audiences");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unrecognizedModeWarnsAboutOAuthFallback() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setMode("api-key"); // near-miss typo that silently runs the OAuth chain
|
||||
|
||||
assertTrue(hasWarn(McpConfigValidator.validate(mcp), "is not recognized"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireExistingAccountFalseWarnsAboutOpenAccess() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setIssuerUri("https://issuer.example.com");
|
||||
mcp.getAuth().setResourceId("https://host.example.com/mcp");
|
||||
mcp.getAuth().setUsernameClaim("email");
|
||||
mcp.getAuth().setRequireExistingAccount(false);
|
||||
|
||||
assertTrue(hasWarn(McpConfigValidator.validate(mcp), "require-existing-account=false"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonUrlResourceIdWarns() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setIssuerUri("https://issuer.example.com");
|
||||
mcp.getAuth().setResourceId("localhost:8080/mcp"); // missing scheme
|
||||
|
||||
assertTrue(hasWarn(McpConfigValidator.validate(mcp), "is not an http(s) URL"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowListIsFlaggedAndOverlapWithBlockListWarns() {
|
||||
ApplicationProperties.Mcp mcp = newMcp();
|
||||
mcp.getAuth().setIssuerUri("https://issuer.example.com");
|
||||
mcp.getAuth().setResourceId("https://host.example.com/mcp");
|
||||
mcp.setAllowedOperations(List.of("merge-pdfs", "split-pdf"));
|
||||
mcp.setBlockedOperations(List.of("split-pdf"));
|
||||
|
||||
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(mcp);
|
||||
|
||||
assertTrue(
|
||||
findings.stream().anyMatch(f -> f.message().contains("strict allow-list")),
|
||||
"an allow-list should be surfaced");
|
||||
assertTrue(hasWarn(findings, "blocked wins"), "allowed+blocked overlap should warn");
|
||||
}
|
||||
}
|
||||
+35
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -149,6 +150,40 @@ class PolicyExecutorTest {
|
||||
verify(internalApiClient, times(2)).post(eq(ROTATE), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noInputGeneratorEndpointIsCalledOnceWithNoFile() throws IOException {
|
||||
// A "create" workflow has no source documents: a generator tool (e.g.
|
||||
// create-pdf-from-html-agent) produces its output purely from parameters. Per-file
|
||||
// dispatch would skip it entirely (zero files = zero calls), so it must still run once.
|
||||
String createPdf = "/api/v1/ai/tools/create-pdf-from-html-agent";
|
||||
when(toolMetadataService.isMultiInput(createPdf)).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(createPdf)).thenReturn(false);
|
||||
stubEndpoint(createPdf, pdf("generated", "purchase-order.pdf"));
|
||||
|
||||
PolicyExecutionResult result =
|
||||
executor.execute(
|
||||
definition(
|
||||
new PipelineStep(
|
||||
createPdf,
|
||||
Map.of(
|
||||
"htmlContent",
|
||||
"<p>hi</p>",
|
||||
"filename",
|
||||
"purchase-order.pdf"))),
|
||||
PolicyInputs.of(List.of()),
|
||||
PolicyProgressListener.NOOP);
|
||||
|
||||
assertEquals(1, result.files().size());
|
||||
assertEquals("purchase-order.pdf", result.files().get(0).getFilename());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<MultiValueMap<String, Object>> bodyCaptor =
|
||||
ArgumentCaptor.forClass(MultiValueMap.class);
|
||||
verify(internalApiClient, times(1)).post(eq(createPdf), bodyCaptor.capture());
|
||||
// No document stream: the body carries only the generator's parameters, no fileInput.
|
||||
assertNull(bodyCaptor.getValue().get("fileInput"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void zipResponseIsUnpackedIntoIndividualFiles() throws IOException {
|
||||
when(toolMetadataService.isMultiInput(SPLIT)).thenReturn(false);
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
+49
-2
@@ -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.
|
||||
|
||||
+3
-49
@@ -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,
|
||||
|
||||
+22
-80
@@ -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);
|
||||
}
|
||||
}
|
||||
-493
@@ -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);
|
||||
|
||||
+1
-3
@@ -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);
|
||||
}
|
||||
-41
@@ -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);
|
||||
}
|
||||
+7
-17
@@ -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)
|
||||
@@ -260,7 +257,7 @@ public class SupabaseSecurityConfig {
|
||||
applicationProperties.getSystem() != null
|
||||
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
|
||||
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty();
|
||||
List<String> origins =
|
||||
List<String> configuredOrigins =
|
||||
operatorOverride
|
||||
? applicationProperties.getSystem().getCorsAllowedOrigins()
|
||||
: List.of(
|
||||
@@ -270,6 +267,18 @@ public class SupabaseSecurityConfig {
|
||||
"https://stirling.com",
|
||||
"https://app.stirling.com",
|
||||
"https://api.stirling.com");
|
||||
// Always allow the desktop (Tauri) app's webview origins so the bundled
|
||||
// desktop client can reach the cloud backend regardless of the operator's
|
||||
// configured web origins. A browser can never present a tauri:// (or
|
||||
// tauri.localhost) origin, so these are desktop-app identities — safe to
|
||||
// allow alongside allowCredentials=true. Mirrors core WebMvcConfig.
|
||||
List<String> origins = new ArrayList<>(configuredOrigins);
|
||||
for (String desktopOrigin :
|
||||
List.of("tauri://localhost", "http://tauri.localhost", "https://tauri.localhost")) {
|
||||
if (!origins.contains(desktopOrigin)) {
|
||||
origins.add(desktopOrigin);
|
||||
}
|
||||
}
|
||||
if (origins.stream().anyMatch(o -> o.contains("*"))) {
|
||||
log.warn(
|
||||
"CORS origins contain a wildcard paired with allowCredentials=true: {}."
|
||||
@@ -287,8 +296,9 @@ public class SupabaseSecurityConfig {
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY"));
|
||||
cfg.setExposedHeaders(List.of("WWW-Authenticate", "X-Credits-Remaining"));
|
||||
"X-API-KEY",
|
||||
"X-Browser-Id"));
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+946
@@ -0,0 +1,946 @@
|
||||
package stirling.software.saas.ai.controller;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
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.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
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;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.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.server.ResponseStatusException;
|
||||
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.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.ai.controller.AiCreateController.AiCreateSessionResponse;
|
||||
import stirling.software.saas.ai.controller.AiCreateController.AiCreateSessionSummary;
|
||||
import stirling.software.saas.ai.controller.AiCreateController.CreateSessionRequest;
|
||||
import stirling.software.saas.ai.controller.AiCreateController.CreateSessionResponse;
|
||||
import stirling.software.saas.ai.controller.AiCreateController.DraftRequest;
|
||||
import stirling.software.saas.ai.controller.AiCreateController.DraftSection;
|
||||
import stirling.software.saas.ai.controller.AiCreateController.OutlineRequest;
|
||||
import stirling.software.saas.ai.controller.AiCreateController.RepromptRequest;
|
||||
import stirling.software.saas.ai.controller.AiCreateController.TemplateRequest;
|
||||
import stirling.software.saas.ai.model.AiCreateSession;
|
||||
import stirling.software.saas.ai.model.AiCreateSessionStatus;
|
||||
import stirling.software.saas.ai.repository.AiCreateSessionRepository.AiCreateSessionSummaryProjection;
|
||||
import stirling.software.saas.ai.service.AiCreateProxyService;
|
||||
import stirling.software.saas.ai.service.AiCreateSessionService;
|
||||
import stirling.software.saas.payg.charge.ChargeContext;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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 path, so each relevant test
|
||||
* seeds an authentication and {@link #clearSecurityContext()} resets it afterwards.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class AiCreateControllerTest {
|
||||
|
||||
@Mock private AiCreateSessionService sessionService;
|
||||
@Mock private AiCreateProxyService proxyService;
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private JobChargeService jobChargeService;
|
||||
|
||||
private AiCreateController controller;
|
||||
|
||||
@org.junit.jupiter.api.BeforeEach
|
||||
void setUp() {
|
||||
controller =
|
||||
new AiCreateController(
|
||||
sessionService, proxyService, userRepository, jobChargeService);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
// createSession + chargeForCreate
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("createSession")
|
||||
class CreateSession {
|
||||
|
||||
@Test
|
||||
@DisplayName("happy path returns 200 with the new sessionId and charges one AI unit")
|
||||
void createSession_happyPath_returnsIdAndCharges() {
|
||||
authenticateWeb(userWithTeam(7L, 100L));
|
||||
AiCreateSession created = session("sess-1", "user-x");
|
||||
when(sessionService.createSession(
|
||||
"write a report", "letter", "tmpl-1", "tex", "preview"))
|
||||
.thenReturn(created);
|
||||
|
||||
CreateSessionRequest req =
|
||||
new CreateSessionRequest(
|
||||
"write a report", "letter", "tmpl-1", "tex", "preview");
|
||||
ResponseEntity<CreateSessionResponse> resp = controller.createSession(req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(resp.getBody()).isNotNull();
|
||||
assertThat(resp.getBody().sessionId()).isEqualTo("sess-1");
|
||||
verify(sessionService)
|
||||
.createSession("write a report", "letter", "tmpl-1", "tex", "preview");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("WEB auth charges a single AI unit with WEB source and the user's team")
|
||||
void createSession_webAuth_chargesAiUnitWithWebSource() {
|
||||
authenticateWeb(userWithTeam(7L, 100L));
|
||||
when(sessionService.createSession(any(), any(), any(), any(), any()))
|
||||
.thenReturn(session("sess-1", "user-x"));
|
||||
|
||||
controller.createSession(new CreateSessionRequest("p", null, null, null, null));
|
||||
|
||||
ArgumentCaptor<ChargeContext> ctx = ArgumentCaptor.forClass(ChargeContext.class);
|
||||
verify(jobChargeService).chargeStandalone(ctx.capture(), eq(1));
|
||||
ChargeContext c = ctx.getValue();
|
||||
assertThat(c.ownerUserId()).isEqualTo(7L);
|
||||
assertThat(c.ownerTeamId()).isEqualTo(100L);
|
||||
assertThat(c.source()).isEqualTo(JobSource.WEB);
|
||||
assertThat(c.processType()).isEqualTo(ProcessType.SINGLE_TOOL);
|
||||
assertThat(c.billingCategory()).isEqualTo(BillingCategory.AI);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("API-key auth charges with API source (AI usage billed the same as web)")
|
||||
void createSession_apiKeyAuth_chargesAiUnitWithApiSource() {
|
||||
User user = userWithTeam(7L, 100L);
|
||||
authenticateApiKey(user);
|
||||
when(sessionService.createSession(any(), any(), any(), any(), any()))
|
||||
.thenReturn(session("sess-1", "user-x"));
|
||||
|
||||
controller.createSession(new CreateSessionRequest("p", null, null, null, null));
|
||||
|
||||
ArgumentCaptor<ChargeContext> ctx = ArgumentCaptor.forClass(ChargeContext.class);
|
||||
verify(jobChargeService).chargeStandalone(ctx.capture(), eq(1));
|
||||
assertThat(ctx.getValue().source()).isEqualTo(JobSource.API);
|
||||
assertThat(ctx.getValue().billingCategory()).isEqualTo(BillingCategory.AI);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null prompt is rejected with 400 and never reaches the service")
|
||||
void createSession_nullPrompt_throwsBadRequest() {
|
||||
CreateSessionRequest req = new CreateSessionRequest(null, null, null, null, null);
|
||||
|
||||
assertThatThrownBy(() -> controller.createSession(req))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST));
|
||||
verifyNoInteractions(sessionService);
|
||||
verifyNoInteractions(jobChargeService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank/whitespace prompt is rejected with 400")
|
||||
void createSession_blankPrompt_throwsBadRequest() {
|
||||
CreateSessionRequest req = new CreateSessionRequest(" ", null, null, null, null);
|
||||
|
||||
assertThatThrownBy(() -> controller.createSession(req))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verifyNoInteractions(sessionService);
|
||||
verifyNoInteractions(jobChargeService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no authentication: session still created, charge is skipped (no NPE)")
|
||||
void createSession_noAuth_skipsChargeButCreatesSession() {
|
||||
SecurityContextHolder.clearContext();
|
||||
when(sessionService.createSession(any(), any(), any(), any(), any()))
|
||||
.thenReturn(session("sess-1", "user-x"));
|
||||
|
||||
ResponseEntity<CreateSessionResponse> resp =
|
||||
controller.createSession(new CreateSessionRequest("p", null, null, null, null));
|
||||
|
||||
assertThat(resp.getBody().sessionId()).isEqualTo("sess-1");
|
||||
verify(jobChargeService, never())
|
||||
.chargeStandalone(any(), org.mockito.ArgumentMatchers.anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("user has no team: charge is skipped (free-grant accounting needs a team)")
|
||||
void createSession_userWithoutTeam_skipsCharge() {
|
||||
User user = new User();
|
||||
user.setId(7L);
|
||||
// No team.
|
||||
authenticateWeb(user);
|
||||
when(sessionService.createSession(any(), any(), any(), any(), any()))
|
||||
.thenReturn(session("sess-1", "user-x"));
|
||||
|
||||
controller.createSession(new CreateSessionRequest("p", null, null, null, null));
|
||||
|
||||
verify(jobChargeService, never())
|
||||
.chargeStandalone(any(), org.mockito.ArgumentMatchers.anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("charge failure is best-effort: session is still returned to the caller")
|
||||
void createSession_chargeThrows_sessionStillSucceeds() {
|
||||
authenticateWeb(userWithTeam(7L, 100L));
|
||||
when(sessionService.createSession(any(), any(), any(), any(), any()))
|
||||
.thenReturn(session("sess-1", "user-x"));
|
||||
when(jobChargeService.chargeStandalone(any(), org.mockito.ArgumentMatchers.anyInt()))
|
||||
.thenThrow(new IllegalStateException("stripe down"));
|
||||
|
||||
ResponseEntity<CreateSessionResponse> resp =
|
||||
controller.createSession(new CreateSessionRequest("p", null, null, null, null));
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(resp.getBody().sessionId()).isEqualTo("sess-1");
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
// deleteSession
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteSession returns 204 and delegates to the service")
|
||||
void deleteSession_returnsNoContentAndDelegates() {
|
||||
ResponseEntity<Void> resp = controller.deleteSession("sess-1");
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
assertThat(resp.getBody()).isNull();
|
||||
verify(sessionService).deleteSessionForCurrentUser("sess-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteSession propagates a not-found from the service")
|
||||
void deleteSession_propagatesServiceError() {
|
||||
org.mockito.Mockito.doThrow(new ResponseStatusException(HttpStatus.NOT_FOUND))
|
||||
.when(sessionService)
|
||||
.deleteSessionForCurrentUser("missing");
|
||||
|
||||
assertThatThrownBy(() -> controller.deleteSession("missing"))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
// getSession + toResponse mapping
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("getSession maps every entity field onto the response record")
|
||||
void getSession_mapsAllFields() {
|
||||
AiCreateSession s = session("sess-1", "user-x");
|
||||
s.setDocType("letter");
|
||||
s.setTemplateId("tmpl-1");
|
||||
s.setTemplateTex("\\documentclass{}");
|
||||
s.setPreviewTex("preview-tex");
|
||||
s.setPromptInitial("first prompt");
|
||||
s.setPromptLatest("latest prompt");
|
||||
s.setOutlineText("- one\n- two");
|
||||
s.setOutlineFilename("outline.txt");
|
||||
s.setOutlineApproved(true);
|
||||
s.setOutlineConstraints("{\"tone\":\"formal\"}");
|
||||
s.setDraftSections("[{\"label\":\"Intro\",\"value\":\"hi\"}]");
|
||||
s.setPolishedLatex("\\section{Intro}");
|
||||
s.setPdfUrl("https://signed/url.pdf");
|
||||
Instant created = Instant.parse("2024-01-01T00:00:00Z");
|
||||
Instant updated = Instant.parse("2024-01-02T00:00:00Z");
|
||||
s.setCreatedAt(created);
|
||||
s.setUpdatedAt(updated);
|
||||
s.setStatus(AiCreateSessionStatus.DRAFT_READY);
|
||||
when(sessionService.getSessionForCurrentUser("sess-1")).thenReturn(s);
|
||||
|
||||
ResponseEntity<AiCreateSessionResponse> resp = controller.getSession("sess-1");
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
AiCreateSessionResponse body = resp.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.sessionId()).isEqualTo("sess-1");
|
||||
assertThat(body.userId()).isEqualTo("user-x");
|
||||
assertThat(body.docType()).isEqualTo("letter");
|
||||
assertThat(body.templateId()).isEqualTo("tmpl-1");
|
||||
assertThat(body.templateTex()).isEqualTo("\\documentclass{}");
|
||||
assertThat(body.previewTex()).isEqualTo("preview-tex");
|
||||
assertThat(body.promptInitial()).isEqualTo("first prompt");
|
||||
assertThat(body.promptLatest()).isEqualTo("latest prompt");
|
||||
assertThat(body.outlineText()).isEqualTo("- one\n- two");
|
||||
assertThat(body.outlineFilename()).isEqualTo("outline.txt");
|
||||
assertThat(body.outlineApproved()).isTrue();
|
||||
assertThat(body.outlineConstraints()).containsEntry("tone", "formal");
|
||||
assertThat(body.draftSections()).containsExactly(new DraftSection("Intro", "hi"));
|
||||
assertThat(body.polishedLatex()).isEqualTo("\\section{Intro}");
|
||||
assertThat(body.pdfUrl()).isEqualTo("https://signed/url.pdf");
|
||||
assertThat(body.createdAt()).isEqualTo(created);
|
||||
assertThat(body.updatedAt()).isEqualTo(updated);
|
||||
assertThat(body.status()).isEqualTo("DRAFT_READY");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getSession with null status maps status to null and null payloads to null")
|
||||
void getSession_nullStatusAndPayloads_mapToNull() {
|
||||
AiCreateSession s = session("sess-1", "user-x");
|
||||
s.setStatus(null);
|
||||
s.setOutlineConstraints(null);
|
||||
s.setDraftSections(null);
|
||||
when(sessionService.getSessionForCurrentUser("sess-1")).thenReturn(s);
|
||||
|
||||
AiCreateSessionResponse body = controller.getSession("sess-1").getBody();
|
||||
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.status()).isNull();
|
||||
assertThat(body.outlineConstraints()).isNull();
|
||||
assertThat(body.draftSections()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getSession with blank payloads parses to null rather than throwing")
|
||||
void getSession_blankPayloads_mapToNull() {
|
||||
AiCreateSession s = session("sess-1", "user-x");
|
||||
s.setOutlineConstraints(" ");
|
||||
s.setDraftSections("");
|
||||
when(sessionService.getSessionForCurrentUser("sess-1")).thenReturn(s);
|
||||
|
||||
AiCreateSessionResponse body = controller.getSession("sess-1").getBody();
|
||||
|
||||
assertThat(body.outlineConstraints()).isNull();
|
||||
assertThat(body.draftSections()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getSession with malformed JSON payloads degrades to null (logged, not thrown)")
|
||||
void getSession_malformedPayloads_mapToNull() {
|
||||
AiCreateSession s = session("sess-1", "user-x");
|
||||
s.setOutlineConstraints("{not-valid-json");
|
||||
s.setDraftSections("[oops");
|
||||
when(sessionService.getSessionForCurrentUser("sess-1")).thenReturn(s);
|
||||
|
||||
AiCreateSessionResponse body = controller.getSession("sess-1").getBody();
|
||||
|
||||
assertThat(body.outlineConstraints()).isNull();
|
||||
assertThat(body.draftSections()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getSession propagates a not-found from the service")
|
||||
void getSession_notFound_propagates() {
|
||||
when(sessionService.getSessionForCurrentUser("missing"))
|
||||
.thenThrow(
|
||||
new ResponseStatusException(HttpStatus.NOT_FOUND, "AI session not found"));
|
||||
|
||||
assertThatThrownBy(() -> controller.getSession("missing"))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
// listSessions + toSummary mapping + page/size clamping
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("listSessions")
|
||||
class ListSessions {
|
||||
|
||||
@Test
|
||||
@DisplayName("maps projections to summaries and forwards includeDrafts")
|
||||
void listSessions_mapsProjections() {
|
||||
AiCreateSessionSummaryProjection p =
|
||||
projection(
|
||||
"sess-1",
|
||||
"letter",
|
||||
"tmpl-1",
|
||||
"latest",
|
||||
"initial",
|
||||
AiCreateSessionStatus.SAVED,
|
||||
"https://pdf",
|
||||
Instant.parse("2024-01-01T00:00:00Z"),
|
||||
Instant.parse("2024-01-02T00:00:00Z"));
|
||||
when(sessionService.listSessionSummariesForCurrentUser(any(), eq(true)))
|
||||
.thenReturn(List.of(p));
|
||||
|
||||
ResponseEntity<List<AiCreateSessionSummary>> resp =
|
||||
controller.listSessions(0, 10, true);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(resp.getBody()).hasSize(1);
|
||||
AiCreateSessionSummary summary = resp.getBody().get(0);
|
||||
assertThat(summary.sessionId()).isEqualTo("sess-1");
|
||||
assertThat(summary.docType()).isEqualTo("letter");
|
||||
assertThat(summary.templateId()).isEqualTo("tmpl-1");
|
||||
assertThat(summary.promptLatest()).isEqualTo("latest");
|
||||
assertThat(summary.promptInitial()).isEqualTo("initial");
|
||||
assertThat(summary.status()).isEqualTo("SAVED");
|
||||
assertThat(summary.pdfUrl()).isEqualTo("https://pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("projection with null status maps to a null status string")
|
||||
void listSessions_nullStatus_mapsToNull() {
|
||||
AiCreateSessionSummaryProjection p =
|
||||
projection("s", null, null, null, null, null, null, null, null);
|
||||
when(sessionService.listSessionSummariesForCurrentUser(any(), eq(false)))
|
||||
.thenReturn(List.of(p));
|
||||
|
||||
AiCreateSessionSummary summary = controller.listSessions(0, 10, false).getBody().get(0);
|
||||
|
||||
assertThat(summary.status()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("negative page is clamped to 0")
|
||||
void listSessions_negativePage_clampedToZero() {
|
||||
when(sessionService.listSessionSummariesForCurrentUser(any(), anyBoolean()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
controller.listSessions(-5, 10, false);
|
||||
|
||||
ArgumentCaptor<org.springframework.data.domain.PageRequest> pr =
|
||||
ArgumentCaptor.forClass(org.springframework.data.domain.PageRequest.class);
|
||||
verify(sessionService).listSessionSummariesForCurrentUser(pr.capture(), eq(false));
|
||||
assertThat(pr.getValue().getPageNumber()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("size above 50 is capped at 50")
|
||||
void listSessions_oversizeSize_cappedAt50() {
|
||||
when(sessionService.listSessionSummariesForCurrentUser(any(), anyBoolean()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
controller.listSessions(0, 9999, false);
|
||||
|
||||
ArgumentCaptor<org.springframework.data.domain.PageRequest> pr =
|
||||
ArgumentCaptor.forClass(org.springframework.data.domain.PageRequest.class);
|
||||
verify(sessionService).listSessionSummariesForCurrentUser(pr.capture(), eq(false));
|
||||
assertThat(pr.getValue().getPageSize()).isEqualTo(50);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("size below 1 is floored to 1")
|
||||
void listSessions_zeroSize_flooredToOne() {
|
||||
when(sessionService.listSessionSummariesForCurrentUser(any(), anyBoolean()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
controller.listSessions(0, 0, false);
|
||||
|
||||
ArgumentCaptor<org.springframework.data.domain.PageRequest> pr =
|
||||
ArgumentCaptor.forClass(org.springframework.data.domain.PageRequest.class);
|
||||
verify(sessionService).listSessionSummariesForCurrentUser(pr.capture(), eq(false));
|
||||
assertThat(pr.getValue().getPageSize()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty result yields an empty list, not null")
|
||||
void listSessions_empty_returnsEmptyList() {
|
||||
when(sessionService.listSessionSummariesForCurrentUser(any(), anyBoolean()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
ResponseEntity<List<AiCreateSessionSummary>> resp =
|
||||
controller.listSessions(0, 10, false);
|
||||
|
||||
assertThat(resp.getBody()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
// updateOutline
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateOutline")
|
||||
class UpdateOutline {
|
||||
|
||||
@Test
|
||||
@DisplayName("serializes constraints to JSON and forwards text + filename")
|
||||
void updateOutline_serializesConstraints() {
|
||||
AiCreateSession updated = session("sess-1", "user-x");
|
||||
when(sessionService.updateOutline(eq("sess-1"), eq("the outline"), eq("o.txt"), any()))
|
||||
.thenReturn(updated);
|
||||
|
||||
OutlineRequest req =
|
||||
new OutlineRequest("the outline", "o.txt", Map.of("tone", "formal"));
|
||||
ResponseEntity<AiCreateSessionResponse> resp = controller.updateOutline("sess-1", req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
ArgumentCaptor<String> payload = ArgumentCaptor.forClass(String.class);
|
||||
verify(sessionService)
|
||||
.updateOutline(eq("sess-1"), eq("the outline"), eq("o.txt"), payload.capture());
|
||||
assertThat(payload.getValue()).contains("\"tone\":\"formal\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null constraints forward a null payload (use AI-generated outline)")
|
||||
void updateOutline_nullConstraints_forwardsNullPayload() {
|
||||
when(sessionService.updateOutline(any(), any(), any(), any()))
|
||||
.thenReturn(session("sess-1", "user-x"));
|
||||
|
||||
controller.updateOutline("sess-1", new OutlineRequest("text", null, null));
|
||||
|
||||
verify(sessionService).updateOutline("sess-1", "text", null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty outline string is allowed (signals AI-generated outline)")
|
||||
void updateOutline_emptyOutlineText_isAllowed() {
|
||||
when(sessionService.updateOutline(any(), any(), any(), any()))
|
||||
.thenReturn(session("sess-1", "user-x"));
|
||||
|
||||
controller.updateOutline("sess-1", new OutlineRequest("", null, null));
|
||||
|
||||
verify(sessionService).updateOutline("sess-1", "", null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null outline text is rejected with 400 before touching the service")
|
||||
void updateOutline_nullText_throwsBadRequest() {
|
||||
OutlineRequest req = new OutlineRequest(null, "o.txt", Map.of("a", "b"));
|
||||
|
||||
assertThatThrownBy(() -> controller.updateOutline("sess-1", req))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST));
|
||||
verifyNoInteractions(sessionService);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
// reprompt
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("reprompt forwards the prompt and returns the mapped session")
|
||||
void reprompt_forwardsPrompt() {
|
||||
AiCreateSession s = session("sess-1", "user-x");
|
||||
s.setStatus(AiCreateSessionStatus.OUTLINE_PENDING);
|
||||
when(sessionService.reprompt("sess-1", "new prompt")).thenReturn(s);
|
||||
|
||||
ResponseEntity<AiCreateSessionResponse> resp =
|
||||
controller.reprompt("sess-1", new RepromptRequest("new prompt"));
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(resp.getBody().sessionId()).isEqualTo("sess-1");
|
||||
assertThat(resp.getBody().status()).isEqualTo("OUTLINE_PENDING");
|
||||
verify(sessionService).reprompt("sess-1", "new prompt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("reprompt with null prompt is rejected with 400")
|
||||
void reprompt_nullPrompt_throwsBadRequest() {
|
||||
assertThatThrownBy(() -> controller.reprompt("sess-1", new RepromptRequest(null)))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verifyNoInteractions(sessionService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("reprompt with blank prompt is rejected with 400")
|
||||
void reprompt_blankPrompt_throwsBadRequest() {
|
||||
assertThatThrownBy(() -> controller.reprompt("sess-1", new RepromptRequest(" ")))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verifyNoInteractions(sessionService);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
// updateDraft
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateDraft")
|
||||
class UpdateDraft {
|
||||
|
||||
@Test
|
||||
@DisplayName("serializes draft sections to a JSON array and forwards it")
|
||||
void updateDraft_serializesSections() {
|
||||
when(sessionService.updateDraftSections(eq("sess-1"), any()))
|
||||
.thenReturn(session("sess-1", "user-x"));
|
||||
|
||||
DraftRequest req =
|
||||
new DraftRequest(
|
||||
List.of(
|
||||
new DraftSection("Intro", "hi"),
|
||||
new DraftSection("Body", "x")));
|
||||
controller.updateDraft("sess-1", req);
|
||||
|
||||
ArgumentCaptor<String> payload = ArgumentCaptor.forClass(String.class);
|
||||
verify(sessionService).updateDraftSections(eq("sess-1"), payload.capture());
|
||||
assertThat(payload.getValue()).contains("\"label\":\"Intro\"");
|
||||
assertThat(payload.getValue()).contains("\"value\":\"hi\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty list is allowed and serialized to []")
|
||||
void updateDraft_emptyList_serializesToEmptyArray() {
|
||||
when(sessionService.updateDraftSections(eq("sess-1"), any()))
|
||||
.thenReturn(session("sess-1", "user-x"));
|
||||
|
||||
controller.updateDraft("sess-1", new DraftRequest(List.of()));
|
||||
|
||||
verify(sessionService).updateDraftSections("sess-1", "[]");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null draft sections is rejected with 400")
|
||||
void updateDraft_nullSections_throwsBadRequest() {
|
||||
assertThatThrownBy(() -> controller.updateDraft("sess-1", new DraftRequest(null)))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST));
|
||||
verifyNoInteractions(sessionService);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
// updateTemplate
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateTemplate")
|
||||
class UpdateTemplate {
|
||||
|
||||
@Test
|
||||
@DisplayName("docType only is accepted and forwarded")
|
||||
void updateTemplate_docTypeOnly() {
|
||||
when(sessionService.updateTemplate("sess-1", "letter", null))
|
||||
.thenReturn(session("sess-1", "user-x"));
|
||||
|
||||
ResponseEntity<AiCreateSessionResponse> resp =
|
||||
controller.updateTemplate("sess-1", new TemplateRequest("letter", null));
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
verify(sessionService).updateTemplate("sess-1", "letter", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("templateId only is accepted and forwarded")
|
||||
void updateTemplate_templateIdOnly() {
|
||||
when(sessionService.updateTemplate("sess-1", null, "tmpl-9"))
|
||||
.thenReturn(session("sess-1", "user-x"));
|
||||
|
||||
controller.updateTemplate("sess-1", new TemplateRequest(null, "tmpl-9"));
|
||||
|
||||
verify(sessionService).updateTemplate("sess-1", null, "tmpl-9");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("both docType and templateId null is rejected with 400")
|
||||
void updateTemplate_bothNull_throwsBadRequest() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
controller.updateTemplate(
|
||||
"sess-1", new TemplateRequest(null, null)))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST));
|
||||
verifyNoInteractions(sessionService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("both docType and templateId blank is rejected with 400")
|
||||
void updateTemplate_bothBlank_throwsBadRequest() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
controller.updateTemplate(
|
||||
"sess-1", new TemplateRequest(" ", "")))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verifyNoInteractions(sessionService);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
// fillFields (proxy, no credit header)
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("fillFields")
|
||||
class FillFields {
|
||||
|
||||
@Test
|
||||
@DisplayName("checks ownership, proxies POST, copies headers, and streams the body through")
|
||||
void fillFields_proxiesAndStreams() throws Exception {
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
HttpResponse<InputStream> upstream =
|
||||
upstreamResponse(
|
||||
200,
|
||||
"section data",
|
||||
httpHeaders(Map.of(HttpHeaders.CONTENT_TYPE, "application/json")));
|
||||
when(proxyService.forward(
|
||||
eq("POST"),
|
||||
eq("/api/create/sessions/sess-1/fields"),
|
||||
eq(req),
|
||||
eq(false)))
|
||||
.thenReturn(upstream);
|
||||
|
||||
ResponseEntity<StreamingResponseBody> resp = controller.fillFields("sess-1", req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
|
||||
.isEqualTo("application/json");
|
||||
// Ownership guard runs before the proxy.
|
||||
verify(sessionService).getSessionForCurrentUser("sess-1");
|
||||
assertThat(drain(resp.getBody())).isEqualTo("section data");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ownership failure short-circuits before proxying")
|
||||
void fillFields_ownershipFailure_doesNotProxy() throws Exception {
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
when(sessionService.getSessionForCurrentUser("sess-1"))
|
||||
.thenThrow(new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
|
||||
assertThatThrownBy(() -> controller.fillFields("sess-1", req))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verify(proxyService, never()).forward(any(), any(), any(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("upstream error: returns 503 with a JSON error body, never throws")
|
||||
void fillFields_proxyThrows_returns503() throws Exception {
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
when(proxyService.forward(any(), any(), any(), anyBoolean()))
|
||||
.thenThrow(new java.io.IOException("backend down"));
|
||||
|
||||
ResponseEntity<StreamingResponseBody> resp = controller.fillFields("sess-1", req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON);
|
||||
assertThat(drain(resp.getBody())).contains("AI backend unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
// stream (proxy, accept event-stream)
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("stream")
|
||||
class Stream {
|
||||
|
||||
@Test
|
||||
@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()));
|
||||
when(proxyService.forward(
|
||||
eq("GET"), eq("/api/create/sessions/sess-1/stream"), eq(req), eq(true)))
|
||||
.thenReturn(upstream);
|
||||
|
||||
ResponseEntity<StreamingResponseBody> resp = controller.stream("sess-1", req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
// No upstream Content-Type → defaulted to text/event-stream.
|
||||
assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
|
||||
.isEqualTo(MediaType.TEXT_EVENT_STREAM_VALUE);
|
||||
// Ownership guard runs before the proxy.
|
||||
verify(sessionService).getSessionForCurrentUser("sess-1");
|
||||
assertThat(drain(resp.getBody())).isEqualTo("data: hi\n\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("upstream non-2xx status is passed through; explicit Content-Type wins")
|
||||
void stream_upstreamStatusAndExplicitContentTypePassedThrough() throws Exception {
|
||||
SecurityContextHolder.clearContext();
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
HttpResponse<InputStream> upstream =
|
||||
upstreamResponse(
|
||||
404,
|
||||
"not found",
|
||||
httpHeaders(
|
||||
Map.of(
|
||||
HttpHeaders.CONTENT_TYPE,
|
||||
"text/plain",
|
||||
HttpHeaders.CACHE_CONTROL,
|
||||
"no-cache")));
|
||||
when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream);
|
||||
|
||||
ResponseEntity<StreamingResponseBody> resp = controller.stream("sess-1", req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
|
||||
.isEqualTo("text/plain");
|
||||
assertThat(resp.getHeaders().getFirst(HttpHeaders.CACHE_CONTROL)).isEqualTo("no-cache");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unmappable upstream status code falls back to 502 Bad Gateway")
|
||||
void stream_unresolvableStatus_fallsBackToBadGateway() throws Exception {
|
||||
SecurityContextHolder.clearContext();
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
// 299 is not a defined HttpStatus enum constant → HttpStatus.resolve returns null.
|
||||
HttpResponse<InputStream> upstream =
|
||||
upstreamResponse(299, "weird", 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.BAD_GATEWAY);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ownership failure short-circuits before proxying")
|
||||
void stream_ownershipFailure_doesNotProxy() throws Exception {
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
when(sessionService.getSessionForCurrentUser("sess-1"))
|
||||
.thenThrow(new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
|
||||
assertThatThrownBy(() -> controller.stream("sess-1", req))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verify(proxyService, never()).forward(any(), any(), any(), anyBoolean());
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
|
||||
private static AiCreateSession session(String sessionId, String userId) {
|
||||
AiCreateSession s = new AiCreateSession();
|
||||
s.setSessionId(sessionId);
|
||||
s.setUserId(userId);
|
||||
return s;
|
||||
}
|
||||
|
||||
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 void authenticateApiKey(User user) {
|
||||
ApiKeyAuthenticationToken auth =
|
||||
new ApiKeyAuthenticationToken(user, "the-api-key", 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)));
|
||||
return java.net.http.HttpHeaders.of(multi, (k, v) -> true);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static HttpResponse<InputStream> upstreamResponse(
|
||||
int status, String body, java.net.http.HttpHeaders headers) {
|
||||
HttpResponse<InputStream> response = mock(HttpResponse.class);
|
||||
when(response.statusCode()).thenReturn(status);
|
||||
when(response.headers()).thenReturn(headers);
|
||||
when(response.body())
|
||||
.thenReturn(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)));
|
||||
return response;
|
||||
}
|
||||
|
||||
private static String drain(StreamingResponseBody body) throws Exception {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
body.writeTo(out);
|
||||
return out.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static AiCreateSessionSummaryProjection projection(
|
||||
String sessionId,
|
||||
String docType,
|
||||
String templateId,
|
||||
String promptLatest,
|
||||
String promptInitial,
|
||||
AiCreateSessionStatus status,
|
||||
String pdfUrl,
|
||||
Instant createdAt,
|
||||
Instant updatedAt) {
|
||||
return new AiCreateSessionSummaryProjection() {
|
||||
@Override
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDocType() {
|
||||
return docType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTemplateId() {
|
||||
return templateId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPromptLatest() {
|
||||
return promptLatest;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPromptInitial() {
|
||||
return promptInitial;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AiCreateSessionStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPdfUrl() {
|
||||
return pdfUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+457
@@ -0,0 +1,457 @@
|
||||
package stirling.software.saas.ai.controller;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.saas.ai.model.AiCreateSession;
|
||||
import stirling.software.saas.ai.model.AiCreateSessionStatus;
|
||||
import stirling.software.saas.ai.service.AiCreateSessionService;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link AiCreateInternalController}. The controller is a thin internal facade over
|
||||
* {@link AiCreateSessionService}: it maps an entity to a response record, and on update serialises
|
||||
* the JSON-shaped fields (outline constraints / draft sections) before delegating. We mock the
|
||||
* service and assert the {@link ResponseEntity} plus the exact arguments forwarded.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AiCreateInternalControllerTest {
|
||||
|
||||
@Mock private AiCreateSessionService sessionService;
|
||||
|
||||
// The controller's @RequiredArgsConstructor only takes sessionService; the ObjectMapper field
|
||||
// is an inline initializer, so a real Jackson instance is exercised by these tests.
|
||||
private AiCreateInternalController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller = new AiCreateInternalController(sessionService);
|
||||
}
|
||||
|
||||
// --- helpers -------------------------------------------------------------------------------
|
||||
|
||||
private static AiCreateSession session(String sessionId) {
|
||||
AiCreateSession s = new AiCreateSession();
|
||||
s.setSessionId(sessionId);
|
||||
s.setUserId("user-1");
|
||||
s.setDocType("report");
|
||||
s.setTemplateId("tmpl-1");
|
||||
s.setTemplateTex("\\documentclass{article}");
|
||||
s.setPreviewTex("preview");
|
||||
s.setPromptInitial("initial prompt");
|
||||
s.setPromptLatest("latest prompt");
|
||||
s.setOutlineText("outline body");
|
||||
s.setOutlineFilename("outline.txt");
|
||||
s.setOutlineApproved(true);
|
||||
s.setPolishedLatex("\\section{x}");
|
||||
s.setPdfUrl("https://example.com/doc.pdf");
|
||||
s.setStatus(AiCreateSessionStatus.DRAFT_READY);
|
||||
return s;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSession")
|
||||
class GetSession {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 200 with the session mapped to a response record")
|
||||
void getSession_mapsAllScalarFields() {
|
||||
AiCreateSession s = session("sess-abc");
|
||||
when(sessionService.getSession("sess-abc")).thenReturn(s);
|
||||
|
||||
ResponseEntity<AiCreateController.AiCreateSessionResponse> resp =
|
||||
controller.getSession("sess-abc");
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
AiCreateController.AiCreateSessionResponse body = resp.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.sessionId()).isEqualTo("sess-abc");
|
||||
assertThat(body.userId()).isEqualTo("user-1");
|
||||
assertThat(body.docType()).isEqualTo("report");
|
||||
assertThat(body.templateId()).isEqualTo("tmpl-1");
|
||||
assertThat(body.templateTex()).isEqualTo("\\documentclass{article}");
|
||||
assertThat(body.previewTex()).isEqualTo("preview");
|
||||
assertThat(body.promptInitial()).isEqualTo("initial prompt");
|
||||
assertThat(body.promptLatest()).isEqualTo("latest prompt");
|
||||
assertThat(body.outlineText()).isEqualTo("outline body");
|
||||
assertThat(body.outlineFilename()).isEqualTo("outline.txt");
|
||||
assertThat(body.outlineApproved()).isTrue();
|
||||
assertThat(body.polishedLatex()).isEqualTo("\\section{x}");
|
||||
assertThat(body.pdfUrl()).isEqualTo("https://example.com/doc.pdf");
|
||||
assertThat(body.status()).isEqualTo("DRAFT_READY");
|
||||
|
||||
verify(sessionService).getSession("sess-abc");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("maps a null status to a null status string, not an NPE")
|
||||
void getSession_nullStatus_mapsToNull() {
|
||||
AiCreateSession s = session("sess-null-status");
|
||||
s.setStatus(null);
|
||||
when(sessionService.getSession("sess-null-status")).thenReturn(s);
|
||||
|
||||
ResponseEntity<AiCreateController.AiCreateSessionResponse> resp =
|
||||
controller.getSession("sess-null-status");
|
||||
|
||||
assertThat(resp.getBody()).isNotNull();
|
||||
assertThat(resp.getBody().status()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("leaves outlineConstraints/draftSections null when the entity stored none")
|
||||
void getSession_noJsonPayloads_yieldsNullCollections() {
|
||||
AiCreateSession s = session("sess-empty-json");
|
||||
s.setOutlineConstraints(null);
|
||||
s.setDraftSections(" "); // blank string is treated as absent
|
||||
when(sessionService.getSession("sess-empty-json")).thenReturn(s);
|
||||
|
||||
AiCreateController.AiCreateSessionResponse body =
|
||||
controller.getSession("sess-empty-json").getBody();
|
||||
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.outlineConstraints()).isNull();
|
||||
assertThat(body.draftSections()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parses stored outlineConstraints/draftSections JSON back into the response")
|
||||
void getSession_parsesStoredJsonPayloads() {
|
||||
AiCreateSession s = session("sess-json");
|
||||
s.setOutlineConstraints("{\"tone\":\"formal\",\"pages\":3}");
|
||||
s.setDraftSections("[{\"label\":\"Intro\",\"value\":\"hello\"}]");
|
||||
when(sessionService.getSession("sess-json")).thenReturn(s);
|
||||
|
||||
AiCreateController.AiCreateSessionResponse body =
|
||||
controller.getSession("sess-json").getBody();
|
||||
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.outlineConstraints())
|
||||
.containsEntry("tone", "formal")
|
||||
.containsEntry("pages", 3);
|
||||
assertThat(body.draftSections()).hasSize(1);
|
||||
assertThat(body.draftSections().get(0).label()).isEqualTo("Intro");
|
||||
assertThat(body.draftSections().get(0).value()).isEqualTo("hello");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("malformed stored JSON is swallowed and surfaces as null, not a 500")
|
||||
void getSession_malformedJson_returnsNullCollections() {
|
||||
AiCreateSession s = session("sess-bad-json");
|
||||
s.setOutlineConstraints("{not valid json");
|
||||
s.setDraftSections("[also not valid");
|
||||
when(sessionService.getSession("sess-bad-json")).thenReturn(s);
|
||||
|
||||
AiCreateController.AiCreateSessionResponse body =
|
||||
controller.getSession("sess-bad-json").getBody();
|
||||
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.outlineConstraints()).isNull();
|
||||
assertThat(body.draftSections()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates a 404 ResponseStatusException from the service")
|
||||
void getSession_notFound_propagates() {
|
||||
when(sessionService.getSession("missing"))
|
||||
.thenThrow(
|
||||
new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "AI session not found"));
|
||||
|
||||
assertThatThrownBy(() -> controller.getSession("missing"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
ex ->
|
||||
assertThat(((ResponseStatusException) ex).getStatusCode())
|
||||
.isEqualTo(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateSession")
|
||||
class UpdateSession {
|
||||
|
||||
@Test
|
||||
@DisplayName("forwards every scalar field and serialises JSON payloads to the service")
|
||||
void updateSession_serialisesPayloadsAndForwardsAllFields() {
|
||||
AiCreateSession updated = session("sess-1");
|
||||
when(sessionService.applyInternalUpdate(
|
||||
eq("sess-1"),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any()))
|
||||
.thenReturn(updated);
|
||||
|
||||
AiCreateInternalController.UpdateSessionRequest req =
|
||||
new AiCreateInternalController.UpdateSessionRequest(
|
||||
"new outline",
|
||||
"new.txt",
|
||||
Boolean.TRUE,
|
||||
Map.of("tone", "casual"),
|
||||
List.of(new AiCreateController.DraftSection("Body", "content")),
|
||||
"\\section{polished}",
|
||||
"https://example.com/out.pdf",
|
||||
"letter",
|
||||
"tmpl-9",
|
||||
AiCreateSessionStatus.POLISHED_READY);
|
||||
|
||||
ResponseEntity<AiCreateController.AiCreateSessionResponse> resp =
|
||||
controller.updateSession("sess-1", req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(resp.getBody()).isNotNull();
|
||||
assertThat(resp.getBody().sessionId()).isEqualTo("sess-1");
|
||||
|
||||
ArgumentCaptor<String> constraints = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<String> sections = ArgumentCaptor.forClass(String.class);
|
||||
verify(sessionService)
|
||||
.applyInternalUpdate(
|
||||
eq("sess-1"),
|
||||
eq("new outline"),
|
||||
eq("new.txt"),
|
||||
eq(Boolean.TRUE),
|
||||
constraints.capture(),
|
||||
sections.capture(),
|
||||
eq("\\section{polished}"),
|
||||
eq("https://example.com/out.pdf"),
|
||||
eq("letter"),
|
||||
eq("tmpl-9"),
|
||||
eq(AiCreateSessionStatus.POLISHED_READY));
|
||||
|
||||
// Constraints serialised to a JSON object string carrying the map entry.
|
||||
assertThat(constraints.getValue()).contains("\"tone\"").contains("\"casual\"");
|
||||
// Draft sections serialised to a JSON array string carrying the record fields.
|
||||
assertThat(sections.getValue())
|
||||
.startsWith("[")
|
||||
.contains("\"label\":\"Body\"")
|
||||
.contains("\"value\":\"content\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"passes null payloads through when outline constraints / draft sections absent")
|
||||
void updateSession_nullCollections_forwardsNullPayloads() {
|
||||
AiCreateSession updated = session("sess-2");
|
||||
when(sessionService.applyInternalUpdate(
|
||||
eq("sess-2"),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull()))
|
||||
.thenReturn(updated);
|
||||
|
||||
AiCreateInternalController.UpdateSessionRequest req =
|
||||
new AiCreateInternalController.UpdateSessionRequest(
|
||||
null, null, null, null, null, null, null, null, null, null);
|
||||
|
||||
ResponseEntity<AiCreateController.AiCreateSessionResponse> resp =
|
||||
controller.updateSession("sess-2", req);
|
||||
|
||||
assertThat(resp.getBody()).isNotNull();
|
||||
// Both JSON-shaped fields forwarded as null (not "null" strings) since they were
|
||||
// absent.
|
||||
verify(sessionService)
|
||||
.applyInternalUpdate(
|
||||
eq("sess-2"),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("serialises an empty constraints map / sections list to '{}' and '[]'")
|
||||
void updateSession_emptyCollections_serialiseToEmptyJson() {
|
||||
when(sessionService.applyInternalUpdate(
|
||||
eq("sess-3"),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any()))
|
||||
.thenReturn(session("sess-3"));
|
||||
|
||||
AiCreateInternalController.UpdateSessionRequest req =
|
||||
new AiCreateInternalController.UpdateSessionRequest(
|
||||
null, null, null, Map.of(), List.of(), null, null, null, null, null);
|
||||
|
||||
controller.updateSession("sess-3", req);
|
||||
|
||||
ArgumentCaptor<String> constraints = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<String> sections = ArgumentCaptor.forClass(String.class);
|
||||
verify(sessionService)
|
||||
.applyInternalUpdate(
|
||||
eq("sess-3"),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
constraints.capture(),
|
||||
sections.capture(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull());
|
||||
// Empty (but present) collections still serialise: distinguishes "absent" from "empty".
|
||||
assertThat(constraints.getValue()).isEqualTo("{}");
|
||||
assertThat(sections.getValue()).isEqualTo("[]");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("response reflects the entity the service returns after the update")
|
||||
void updateSession_responseReflectsReturnedEntity() {
|
||||
AiCreateSession returned = session("sess-4");
|
||||
returned.setStatus(AiCreateSessionStatus.SAVED);
|
||||
returned.setPdfUrl("https://example.com/final.pdf");
|
||||
when(sessionService.applyInternalUpdate(
|
||||
eq("sess-4"),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any()))
|
||||
.thenReturn(returned);
|
||||
|
||||
AiCreateInternalController.UpdateSessionRequest req =
|
||||
new AiCreateInternalController.UpdateSessionRequest(
|
||||
"x", null, null, null, null, null, null, null, null, null);
|
||||
|
||||
AiCreateController.AiCreateSessionResponse body =
|
||||
controller.updateSession("sess-4", req).getBody();
|
||||
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.status()).isEqualTo("SAVED");
|
||||
assertThat(body.pdfUrl()).isEqualTo("https://example.com/final.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("propagates a 404 when the service cannot find the session to update")
|
||||
void updateSession_notFound_propagates() {
|
||||
when(sessionService.applyInternalUpdate(
|
||||
eq("missing"),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any()))
|
||||
.thenThrow(
|
||||
new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "AI session not found"));
|
||||
|
||||
AiCreateInternalController.UpdateSessionRequest req =
|
||||
new AiCreateInternalController.UpdateSessionRequest(
|
||||
"x", null, null, null, null, null, null, null, null, null);
|
||||
|
||||
assertThatThrownBy(() -> controller.updateSession("missing", req))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
ex ->
|
||||
assertThat(((ResponseStatusException) ex).getStatusCode())
|
||||
.isEqualTo(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("round-trip: serialised draft sections parse back identically in the response")
|
||||
void updateSession_draftSectionsRoundTrip() {
|
||||
// Service echoes back the payload it was handed so we can confirm serialise -> store ->
|
||||
// parse is lossless for the DraftSection shape.
|
||||
when(sessionService.applyInternalUpdate(
|
||||
eq("sess-rt"),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
AiCreateSession s = session("sess-rt");
|
||||
s.setOutlineConstraints((String) inv.getArgument(4));
|
||||
s.setDraftSections((String) inv.getArgument(5));
|
||||
return s;
|
||||
});
|
||||
|
||||
AiCreateInternalController.UpdateSessionRequest req =
|
||||
new AiCreateInternalController.UpdateSessionRequest(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
Map.of("depth", "deep"),
|
||||
List.of(
|
||||
new AiCreateController.DraftSection("A", "1"),
|
||||
new AiCreateController.DraftSection("B", "2")),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
AiCreateController.AiCreateSessionResponse body =
|
||||
controller.updateSession("sess-rt", req).getBody();
|
||||
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.outlineConstraints()).containsEntry("depth", "deep");
|
||||
assertThat(body.draftSections()).hasSize(2);
|
||||
assertThat(body.draftSections().get(0).label()).isEqualTo("A");
|
||||
assertThat(body.draftSections().get(0).value()).isEqualTo("1");
|
||||
assertThat(body.draftSections().get(1).label()).isEqualTo("B");
|
||||
assertThat(body.draftSections().get(1).value()).isEqualTo("2");
|
||||
}
|
||||
}
|
||||
}
|
||||
+569
@@ -0,0 +1,569 @@
|
||||
package stirling.software.saas.ai.controller;
|
||||
|
||||
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.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.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.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.saas.ai.service.AiProxyService;
|
||||
|
||||
/**
|
||||
* 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)} 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, and the 503 error fallback.
|
||||
* </ol>
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class AiProxyControllerTest {
|
||||
|
||||
@Mock private AiProxyService aiProxyService;
|
||||
|
||||
private AiProxyController controller;
|
||||
|
||||
@org.junit.jupiter.api.BeforeEach
|
||||
void setUp() {
|
||||
controller = new AiProxyController(aiProxyService);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
// Endpoint path/method mapping — each handler pins the exact upstream contract it forwards.
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("endpoint → upstream (method, path, acceptEventStream) mapping")
|
||||
class EndpointMapping {
|
||||
|
||||
@Test
|
||||
@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"));
|
||||
|
||||
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
verify(aiProxyService).forward("POST", "/api/generate_section", req, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("generateAllSections POSTs to /api/generate_all_sections")
|
||||
void generateAllSections() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
stubForward("POST", "/api/generate_all_sections", req, false, ok("body"));
|
||||
|
||||
controller.generateAllSections(req);
|
||||
|
||||
verify(aiProxyService).forward("POST", "/api/generate_all_sections", req, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("intentCheck POSTs to /api/intent/check")
|
||||
void intentCheck() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
stubForward("POST", "/api/intent/check", req, false, ok("body"));
|
||||
|
||||
controller.intentCheck(req);
|
||||
|
||||
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 {
|
||||
HttpServletRequest req = req();
|
||||
stubForward("POST", "/api/pdf/answer", req, false, ok("body"));
|
||||
|
||||
controller.pdfAnswer(req);
|
||||
|
||||
verify(aiProxyService).forward("POST", "/api/pdf/answer", req, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("progressiveRender POSTs to /api/progressive_render")
|
||||
void progressiveRender() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
stubForward("POST", "/api/progressive_render", req, false, ok("body"));
|
||||
|
||||
controller.progressiveRender(req);
|
||||
|
||||
verify(aiProxyService).forward("POST", "/api/progressive_render", req, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("versions GETs /api/versions/{userId} with the path variable interpolated")
|
||||
void versions() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
stubForward("GET", "/api/versions/user-42", req, false, ok("body"));
|
||||
|
||||
controller.versions("user-42", req);
|
||||
|
||||
verify(aiProxyService).forward("GET", "/api/versions/user-42", req, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("style (GET) GETs /api/style/{userId}")
|
||||
void styleGet() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
stubForward("GET", "/api/style/user-42", req, false, ok("body"));
|
||||
|
||||
controller.style("user-42", req);
|
||||
|
||||
verify(aiProxyService).forward("GET", "/api/style/user-42", req, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("updateStyle POSTs /api/style/{userId}")
|
||||
void updateStyle() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
stubForward("POST", "/api/style/user-42", req, false, ok("body"));
|
||||
|
||||
controller.updateStyle("user-42", req);
|
||||
|
||||
verify(aiProxyService).forward("POST", "/api/style/user-42", req, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("importTemplate POSTs /api/import_template")
|
||||
void importTemplate() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
stubForward("POST", "/api/import_template", req, false, ok("body"));
|
||||
|
||||
controller.importTemplate(req);
|
||||
|
||||
verify(aiProxyService).forward("POST", "/api/import_template", req, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createEditSession POSTs /api/edit/sessions")
|
||||
void createEditSession() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
stubForward("POST", "/api/edit/sessions", req, false, ok("body"));
|
||||
|
||||
controller.createEditSession(req);
|
||||
|
||||
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 {
|
||||
HttpServletRequest req = req();
|
||||
stubForward("POST", "/api/edit/sessions/sess-9/attachments", req, false, ok("body"));
|
||||
|
||||
controller.editSessionAttachment("sess-9", req);
|
||||
|
||||
verify(aiProxyService)
|
||||
.forward("POST", "/api/edit/sessions/sess-9/attachments", req, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("runEditSession POSTs /api/edit/sessions/{id}/run as an event stream")
|
||||
void runEditSession() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
// acceptEventStream == true here.
|
||||
stubForward("POST", "/api/edit/sessions/sess-9/run", req, true, ok("data: x\n\n"));
|
||||
|
||||
controller.runEditSession("sess-9", req);
|
||||
|
||||
verify(aiProxyService).forward("POST", "/api/edit/sessions/sess-9/run", req, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pdfEditorDocument GETs /api/pdf-editor/document")
|
||||
void pdfEditorDocument() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
stubForward("GET", "/api/pdf-editor/document", req, false, ok("body"));
|
||||
|
||||
controller.pdfEditorDocument(req);
|
||||
|
||||
verify(aiProxyService).forward("GET", "/api/pdf-editor/document", req, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pdfEditorUpload POSTs /api/pdf-editor/upload")
|
||||
void pdfEditorUpload() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
stubForward("POST", "/api/pdf-editor/upload", req, false, ok("body"));
|
||||
|
||||
controller.pdfEditorUpload(req);
|
||||
|
||||
verify(aiProxyService).forward("POST", "/api/pdf-editor/upload", req, false);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
// output(**) — derives the upstream path from the raw request URI minus the proxy prefix.
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("output(**) wildcard path derivation")
|
||||
class OutputPathDerivation {
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"strips the contextPath + /api/v1/ai/output/ prefix and forwards the remainder")
|
||||
void output_stripsPrefixAndForwardsRemainder() throws Exception {
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
when(req.getContextPath()).thenReturn("");
|
||||
when(req.getRequestURI()).thenReturn("/api/v1/ai/output/foo/bar.png");
|
||||
stubForward("GET", "/output/foo/bar.png", req, false, ok("img-bytes"));
|
||||
|
||||
ResponseEntity<StreamingResponseBody> resp = controller.output(req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
verify(aiProxyService).forward("GET", "/output/foo/bar.png", req, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("honours a non-empty servlet contextPath when computing the prefix")
|
||||
void output_honoursContextPath() throws Exception {
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
when(req.getContextPath()).thenReturn("/stirling");
|
||||
when(req.getRequestURI()).thenReturn("/stirling/api/v1/ai/output/nested/file.pdf");
|
||||
stubForward("GET", "/output/nested/file.pdf", req, false, ok("pdf"));
|
||||
|
||||
controller.output(req);
|
||||
|
||||
verify(aiProxyService).forward("GET", "/output/nested/file.pdf", req, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("URI not under the expected prefix yields an empty remainder (path /output/)")
|
||||
void output_uriOutsidePrefix_emptyRemainder() throws Exception {
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
when(req.getContextPath()).thenReturn("");
|
||||
// Does not start with /api/v1/ai/output/ → substring branch skipped, path stays "".
|
||||
when(req.getRequestURI()).thenReturn("/totally/different");
|
||||
stubForward("GET", "/output/", req, false, ok("body"));
|
||||
|
||||
controller.output(req);
|
||||
|
||||
verify(aiProxyService).forward("GET", "/output/", req, false);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
// Shared proxy body — header copy + status resolution + streaming.
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("proxy() header copy, status resolution and streaming")
|
||||
class ProxyBody {
|
||||
|
||||
@Test
|
||||
@DisplayName("copies the whitelisted upstream headers onto the response")
|
||||
void copiesWhitelistedHeaders() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
HttpResponse<InputStream> upstream =
|
||||
upstreamResponse(
|
||||
200,
|
||||
"payload",
|
||||
httpHeaders(
|
||||
Map.of(
|
||||
HttpHeaders.CONTENT_TYPE,
|
||||
"application/json",
|
||||
HttpHeaders.CACHE_CONTROL,
|
||||
"no-cache",
|
||||
"X-Accel-Buffering",
|
||||
"no",
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=a.pdf",
|
||||
HttpHeaders.CONTENT_LENGTH,
|
||||
"7")));
|
||||
when(aiProxyService.forward(any(), any(), any(), anyBoolean())).thenReturn(upstream);
|
||||
|
||||
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
|
||||
|
||||
HttpHeaders h = resp.getHeaders();
|
||||
assertThat(h.getFirst(HttpHeaders.CONTENT_TYPE)).isEqualTo("application/json");
|
||||
assertThat(h.getFirst(HttpHeaders.CACHE_CONTROL)).isEqualTo("no-cache");
|
||||
assertThat(h.getFirst("X-Accel-Buffering")).isEqualTo("no");
|
||||
assertThat(h.getFirst(HttpHeaders.CONTENT_DISPOSITION))
|
||||
.isEqualTo("attachment; filename=a.pdf");
|
||||
assertThat(h.getFirst(HttpHeaders.CONTENT_LENGTH)).isEqualTo("7");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("streams the upstream body straight through to the output stream")
|
||||
void streamsBodyThrough() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
stubForward("POST", "/api/generate_section", req, false, ok("hello-stream"));
|
||||
|
||||
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
|
||||
|
||||
assertThat(drain(resp.getBody())).isEqualTo("hello-stream");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("upstream non-2xx status is passed through verbatim")
|
||||
void passesThroughUpstreamStatus() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
HttpResponse<InputStream> upstream =
|
||||
upstreamResponse(404, "nope", httpHeaders(Map.of()));
|
||||
when(aiProxyService.forward(any(), any(), any(), anyBoolean())).thenReturn(upstream);
|
||||
|
||||
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unmappable upstream status (299) falls back to 502 Bad Gateway")
|
||||
void unmappableStatus_fallsBackToBadGateway() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
// 299 is not a defined HttpStatus enum constant → HttpStatus.resolve returns null.
|
||||
HttpResponse<InputStream> upstream =
|
||||
upstreamResponse(299, "weird", httpHeaders(Map.of()));
|
||||
when(aiProxyService.forward(any(), any(), any(), anyBoolean())).thenReturn(upstream);
|
||||
|
||||
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"event-stream endpoint with no upstream Content-Type defaults to text/event-stream")
|
||||
void eventStreamDefaultsContentType() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
// runEditSession uses acceptEventStream == true.
|
||||
HttpResponse<InputStream> upstream =
|
||||
upstreamResponse(200, "data: x\n\n", httpHeaders(Map.of()));
|
||||
when(aiProxyService.forward(
|
||||
eq("POST"), eq("/api/edit/sessions/s/run"), eq(req), eq(true)))
|
||||
.thenReturn(upstream);
|
||||
|
||||
ResponseEntity<StreamingResponseBody> resp = controller.runEditSession("s", req);
|
||||
|
||||
assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
|
||||
.isEqualTo(MediaType.TEXT_EVENT_STREAM_VALUE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("event-stream endpoint keeps an explicit upstream Content-Type (no override)")
|
||||
void eventStreamKeepsExplicitContentType() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
HttpResponse<InputStream> upstream =
|
||||
upstreamResponse(
|
||||
200,
|
||||
"data: x\n\n",
|
||||
httpHeaders(Map.of(HttpHeaders.CONTENT_TYPE, "text/plain")));
|
||||
when(aiProxyService.forward(
|
||||
eq("POST"), eq("/api/edit/sessions/s/run"), eq(req), eq(true)))
|
||||
.thenReturn(upstream);
|
||||
|
||||
ResponseEntity<StreamingResponseBody> resp = controller.runEditSession("s", req);
|
||||
|
||||
assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
|
||||
.isEqualTo("text/plain");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-event-stream endpoint with no upstream Content-Type leaves it unset")
|
||||
void nonEventStream_noContentType_leavesUnset() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
stubForward("POST", "/api/generate_section", req, false, ok("body"));
|
||||
|
||||
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
|
||||
|
||||
assertThat(resp.getHeaders().containsHeader(HttpHeaders.CONTENT_TYPE)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a header carrying CR/LF injection is dropped, not copied")
|
||||
void crlfInjectionHeaderDropped() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
HttpResponse<InputStream> upstream =
|
||||
upstreamResponse(
|
||||
200,
|
||||
"body",
|
||||
httpHeaders(
|
||||
Map.of(
|
||||
HttpHeaders.CACHE_CONTROL,
|
||||
"no-cache\r\nX-Injected: evil")));
|
||||
when(aiProxyService.forward(any(), any(), any(), anyBoolean())).thenReturn(upstream);
|
||||
|
||||
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
|
||||
|
||||
assertThat(resp.getHeaders().containsHeader(HttpHeaders.CACHE_CONTROL)).isFalse();
|
||||
assertThat(resp.getHeaders().containsHeader("X-Injected")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("absent upstream headers are simply omitted (no blank values set)")
|
||||
void absentHeadersOmitted() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
stubForward("POST", "/api/generate_section", req, false, ok("body"));
|
||||
|
||||
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
|
||||
|
||||
assertThat(resp.getHeaders().containsHeader(HttpHeaders.CACHE_CONTROL)).isFalse();
|
||||
assertThat(resp.getHeaders().containsHeader(HttpHeaders.CONTENT_DISPOSITION)).isFalse();
|
||||
assertThat(resp.getHeaders().containsHeader("X-Accel-Buffering")).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
// Error fallback — any forward() failure becomes a 503 with a JSON error body, never throws.
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("error fallback")
|
||||
class ErrorFallback {
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException from forward yields 503 + JSON error body")
|
||||
void ioException_returns503() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
when(aiProxyService.forward(any(), any(), any(), anyBoolean()))
|
||||
.thenThrow(new java.io.IOException("backend down"));
|
||||
|
||||
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON);
|
||||
assertThat(drain(resp.getBody())).contains("AI backend unavailable");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("InterruptedException from forward also degrades to a 503, never propagates")
|
||||
void interruptedException_returns503() throws Exception {
|
||||
HttpServletRequest req = req();
|
||||
when(aiProxyService.forward(any(), any(), any(), anyBoolean()))
|
||||
.thenThrow(new InterruptedException("interrupted"));
|
||||
|
||||
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(drain(resp.getBody())).contains("AI backend unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ----------------------------------------------------------------------------------------------
|
||||
|
||||
/** A bare mocked request — these handlers never read from it directly (the service does). */
|
||||
private static HttpServletRequest req() {
|
||||
return mock(HttpServletRequest.class);
|
||||
}
|
||||
|
||||
private void stubForward(
|
||||
String method,
|
||||
String path,
|
||||
HttpServletRequest req,
|
||||
boolean acceptEventStream,
|
||||
HttpResponse<InputStream> response)
|
||||
throws Exception {
|
||||
when(aiProxyService.forward(eq(method), eq(path), eq(req), eq(acceptEventStream)))
|
||||
.thenReturn(response);
|
||||
}
|
||||
|
||||
private static HttpResponse<InputStream> ok(String body) {
|
||||
return upstreamResponse(200, body, httpHeaders(Map.of()));
|
||||
}
|
||||
|
||||
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)));
|
||||
return java.net.http.HttpHeaders.of(multi, (k, v) -> true);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static HttpResponse<InputStream> upstreamResponse(
|
||||
int status, String body, java.net.http.HttpHeaders headers) {
|
||||
HttpResponse<InputStream> response = mock(HttpResponse.class);
|
||||
when(response.statusCode()).thenReturn(status);
|
||||
when(response.headers()).thenReturn(headers);
|
||||
when(response.body())
|
||||
.thenReturn(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)));
|
||||
return response;
|
||||
}
|
||||
|
||||
private static String drain(StreamingResponseBody body) throws Exception {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
body.writeTo(out);
|
||||
return out.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
+676
@@ -0,0 +1,676 @@
|
||||
package stirling.software.saas.ai.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.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import jakarta.servlet.ServletInputStream;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link AiCreateProxyService}.
|
||||
*
|
||||
* <p>The service forwards an inbound HTTP request to the AI "create" backend. It builds its own
|
||||
* {@link HttpClient} in the constructor (no injection), so each test swaps in a mocked client via
|
||||
* {@link ReflectionTestUtils} and captures the outgoing {@link HttpRequest} to assert on the URL,
|
||||
* method and headers. All collaborators ({@link HttpServletRequest}, {@link UserService}, {@link
|
||||
* UserRepository}) are mocked; no Spring context, DB or real network is involved.
|
||||
*
|
||||
* <p>Header semantics under test: Content-Type and Authorization are forwarded only when present
|
||||
* and non-blank; X-API-KEY is taken from the inbound header first and otherwise resolved from the
|
||||
* authenticated user (any lookup failure is swallowed); Accept is overridden to {@code
|
||||
* text/event-stream} when SSE is requested. GET/DELETE send no body; other methods stream the
|
||||
* request input stream lazily.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class AiCreateProxyServiceTest {
|
||||
|
||||
private static final String BASE_URL = "http://ai-backend:5001";
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private UserService userService;
|
||||
@Mock private HttpServletRequest request;
|
||||
@Mock private HttpClient httpClient;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private final HttpResponse<InputStream> response =
|
||||
(HttpResponse<InputStream>) org.mockito.Mockito.mock(HttpResponse.class);
|
||||
|
||||
private AiCreateProxyService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
service = new AiCreateProxyService(BASE_URL, userRepository, userService);
|
||||
// Swap the internally-built client for our mock so no real network call happens.
|
||||
ReflectionTestUtils.setField(service, "httpClient", httpClient);
|
||||
// Default: the mocked client returns our stub response for any send().
|
||||
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
|
||||
.thenReturn(response);
|
||||
}
|
||||
|
||||
/** Capture the single HttpRequest the service hands to the client. */
|
||||
private HttpRequest captureSentRequest() throws Exception {
|
||||
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
|
||||
verify(httpClient).send(captor.capture(), any(HttpResponse.BodyHandler.class));
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private static String header(HttpRequest req, String name) {
|
||||
return req.headers().firstValue(name).orElse(null);
|
||||
}
|
||||
|
||||
/** Build a fresh service backed by the shared mock client for base-URL variations. */
|
||||
private AiCreateProxyService serviceWithBase(String base) {
|
||||
AiCreateProxyService svc = new AiCreateProxyService(base, userRepository, userService);
|
||||
ReflectionTestUtils.setField(svc, "httpClient", httpClient);
|
||||
return svc;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("target URL assembly")
|
||||
class UrlAssembly {
|
||||
|
||||
@Test
|
||||
@DisplayName("joins base + leading-slash path + query string")
|
||||
void joinsBasePathAndQuery() throws Exception {
|
||||
when(request.getQueryString()).thenReturn("model=foo&n=2");
|
||||
|
||||
service.forward("GET", "/v1/chat", request, false);
|
||||
|
||||
assertThat(captureSentRequest().uri())
|
||||
.isEqualTo(URI.create("http://ai-backend:5001/v1/chat?model=foo&n=2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("prepends a slash when the path lacks one")
|
||||
void prependsMissingSlash() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
service.forward("GET", "v1/health", request, false);
|
||||
|
||||
assertThat(captureSentRequest().uri())
|
||||
.isEqualTo(URI.create("http://ai-backend:5001/v1/health"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null query string is ignored")
|
||||
void nullQueryIgnored() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
service.forward("GET", "/v1/ping", request, false);
|
||||
|
||||
assertThat(captureSentRequest().uri())
|
||||
.isEqualTo(URI.create("http://ai-backend:5001/v1/ping"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank query string is ignored")
|
||||
void blankQueryIgnored() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(" ");
|
||||
|
||||
service.forward("GET", "/v1/ping", request, false);
|
||||
|
||||
assertThat(captureSentRequest().uri())
|
||||
.isEqualTo(URI.create("http://ai-backend:5001/v1/ping"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("trailing slash on the configured base URL is trimmed")
|
||||
void trimsTrailingSlashOnBase() throws Exception {
|
||||
AiCreateProxyService svc = serviceWithBase("http://ai-backend:5001/");
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
svc.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().uri())
|
||||
.isEqualTo(URI.create("http://ai-backend:5001/v1/x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("surrounding whitespace on the base URL is trimmed")
|
||||
void trimsWhitespaceOnBase() throws Exception {
|
||||
AiCreateProxyService svc = serviceWithBase(" http://ai-backend:5001 ");
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
svc.forward("GET", "/v1/y", request, false);
|
||||
|
||||
assertThat(captureSentRequest().uri())
|
||||
.isEqualTo(URI.create("http://ai-backend:5001/v1/y"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank base URL falls back to the localhost default")
|
||||
void blankBaseUrlFallsBackToDefault() throws Exception {
|
||||
AiCreateProxyService svc = serviceWithBase(" ");
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
svc.forward("GET", "/v1/z", request, false);
|
||||
|
||||
assertThat(captureSentRequest().uri())
|
||||
.isEqualTo(URI.create("http://localhost:5001/v1/z"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null base URL falls back to the localhost default")
|
||||
void nullBaseUrlFallsBackToDefault() throws Exception {
|
||||
AiCreateProxyService svc = serviceWithBase(null);
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
svc.forward("GET", "/v1/q", request, false);
|
||||
|
||||
assertThat(captureSentRequest().uri())
|
||||
.isEqualTo(URI.create("http://localhost:5001/v1/q"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Content-Type header forwarding")
|
||||
class ContentTypeForwarding {
|
||||
|
||||
@Test
|
||||
@DisplayName("forwards a present inbound Content-Type on a body-bearing request")
|
||||
void forwardsPresentContentType() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getContentType()).thenReturn("application/json");
|
||||
when(request.getInputStream())
|
||||
.thenReturn(servletInputStream("{}".getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
service.forward("POST", "/v1/chat", request, false);
|
||||
|
||||
assertThat(header(captureSentRequest(), "Content-Type")).isEqualTo("application/json");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("omits Content-Type when the inbound value is null")
|
||||
void omitsWhenNull() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getContentType()).thenReturn(null);
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("Content-Type")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("omits Content-Type when the inbound value is blank")
|
||||
void omitsWhenBlank() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getContentType()).thenReturn(" ");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("Content-Type")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Authorization header forwarding")
|
||||
class AuthorizationForwarding {
|
||||
|
||||
@Test
|
||||
@DisplayName("forwards a present Authorization header verbatim")
|
||||
void forwardsPresentAuth() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("Authorization")).thenReturn("Bearer abc.def");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(header(captureSentRequest(), "Authorization")).isEqualTo("Bearer abc.def");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("omits the header when Authorization is null")
|
||||
void omitsWhenNull() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("Authorization")).thenReturn(null);
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("Authorization")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("omits the header when Authorization is blank")
|
||||
void omitsWhenBlank() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("Authorization")).thenReturn(" ");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("Authorization")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("X-API-KEY resolution")
|
||||
class ApiKeyResolution {
|
||||
|
||||
@Test
|
||||
@DisplayName("uses the X-API-KEY header from the request when present (no user lookup)")
|
||||
void usesRequestHeaderAndSkipsUserLookup() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("X-API-KEY")).thenReturn("req-key-123");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("req-key-123");
|
||||
// Header short-circuits the authenticated-user fallback entirely.
|
||||
verifyNoInteractions(userService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to the authenticated user's API key when the header is absent")
|
||||
void fallsBackToUserApiKey() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("X-API-KEY")).thenReturn(null);
|
||||
when(userService.getCurrentUsername()).thenReturn("alice");
|
||||
when(userService.getApiKeyForUser("alice")).thenReturn("user-key-xyz");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("user-key-xyz");
|
||||
verify(userService).getApiKeyForUser("alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to the user key when the inbound header is blank")
|
||||
void blankHeaderTriggersFallback() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("X-API-KEY")).thenReturn(" ");
|
||||
when(userService.getCurrentUsername()).thenReturn("bob");
|
||||
when(userService.getApiKeyForUser("bob")).thenReturn("bob-key");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("bob-key");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no X-API-KEY header is set when there is no authenticated user")
|
||||
void noUser_noHeader() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("X-API-KEY")).thenReturn(null);
|
||||
when(userService.getCurrentUsername()).thenReturn(null);
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
|
||||
// Username was null/blank, so the key lookup is never attempted.
|
||||
verify(userService, never()).getApiKeyForUser(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank username from the security context yields no header")
|
||||
void blankUsername_noHeader() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("X-API-KEY")).thenReturn(null);
|
||||
when(userService.getCurrentUsername()).thenReturn(" ");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
|
||||
verify(userService, never()).getApiKeyForUser(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a resolved-but-blank user key is not forwarded")
|
||||
void blankResolvedKey_noHeader() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("X-API-KEY")).thenReturn(null);
|
||||
when(userService.getCurrentUsername()).thenReturn("carol");
|
||||
when(userService.getApiKeyForUser("carol")).thenReturn("");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a null resolved user key is not forwarded")
|
||||
void nullResolvedKey_noHeader() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("X-API-KEY")).thenReturn(null);
|
||||
when(userService.getCurrentUsername()).thenReturn("dan");
|
||||
when(userService.getApiKeyForUser("dan")).thenReturn(null);
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an exception while resolving the user key is swallowed; no header forwarded")
|
||||
void userKeyLookupThrows_isSwallowed() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("X-API-KEY")).thenReturn(null);
|
||||
when(userService.getCurrentUsername()).thenReturn("erin");
|
||||
when(userService.getApiKeyForUser("erin"))
|
||||
.thenThrow(new RuntimeException("key store offline"));
|
||||
|
||||
// Must not propagate: extractUserApiKey() catches and returns null.
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Accept header handling")
|
||||
class AcceptHandling {
|
||||
|
||||
@Test
|
||||
@DisplayName("acceptEventStream overrides any inbound Accept with text/event-stream")
|
||||
void eventStreamOverrides() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("Accept")).thenReturn("application/json");
|
||||
|
||||
service.forward("GET", "/v1/stream", request, true);
|
||||
|
||||
assertThat(header(captureSentRequest(), "Accept")).isEqualTo("text/event-stream");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("event stream is requested even with no inbound Accept header")
|
||||
void eventStreamWithoutInboundAccept() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("Accept")).thenReturn(null);
|
||||
|
||||
service.forward("GET", "/v1/stream", request, true);
|
||||
|
||||
assertThat(header(captureSentRequest(), "Accept")).isEqualTo("text/event-stream");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("passes a non-stream Accept header through unchanged")
|
||||
void passesInboundAcceptThrough() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("Accept")).thenReturn("application/json");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(header(captureSentRequest(), "Accept")).isEqualTo("application/json");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no Accept header set when inbound Accept is absent and SSE not requested")
|
||||
void noAcceptWhenAbsentAndNotStreaming() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("Accept")).thenReturn(null);
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("Accept")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank inbound Accept is not forwarded when SSE not requested")
|
||||
void blankAcceptNotForwarded() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("Accept")).thenReturn(" ");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("Accept")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("HTTP method and body publisher selection")
|
||||
class MethodAndBody {
|
||||
|
||||
@Test
|
||||
@DisplayName("GET sends an empty body and never reads the request input stream")
|
||||
void getHasNoBody() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
HttpRequest sent = captureSentRequest();
|
||||
assertThat(sent.method()).isEqualTo("GET");
|
||||
assertThat(sent.bodyPublisher()).isPresent();
|
||||
assertThat(sent.bodyPublisher().get().contentLength()).isZero();
|
||||
// GET/DELETE short-circuit before touching the body.
|
||||
verify(request, never()).getInputStream();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DELETE sends an empty body and never reads the request input stream")
|
||||
void deleteHasNoBody() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
service.forward("DELETE", "/v1/item/9", request, false);
|
||||
|
||||
HttpRequest sent = captureSentRequest();
|
||||
assertThat(sent.method()).isEqualTo("DELETE");
|
||||
assertThat(sent.bodyPublisher().get().contentLength()).isZero();
|
||||
verify(request, never()).getInputStream();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("method name matching is case-insensitive for the no-body branch")
|
||||
void lowercaseGetStillNoBody() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
service.forward("get", "/v1/x", request, false);
|
||||
|
||||
HttpRequest sent = captureSentRequest();
|
||||
assertThat(sent.method()).isEqualToIgnoringCase("get");
|
||||
assertThat(sent.bodyPublisher().get().contentLength()).isZero();
|
||||
verify(request, never()).getInputStream();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("lowercase delete also routes through the no-body branch")
|
||||
void lowercaseDeleteNoBody() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
service.forward("delete", "/v1/item/1", request, false);
|
||||
|
||||
HttpRequest sent = captureSentRequest();
|
||||
assertThat(sent.bodyPublisher().get().contentLength()).isZero();
|
||||
verify(request, never()).getInputStream();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST streams the request input stream as an unknown-length body")
|
||||
void postStreamsInputStream() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getContentType()).thenReturn("application/json");
|
||||
byte[] payload = "{\"x\":1}".getBytes(StandardCharsets.UTF_8);
|
||||
when(request.getInputStream()).thenReturn(servletInputStream(payload));
|
||||
|
||||
service.forward("POST", "/v1/chat", request, false);
|
||||
|
||||
HttpRequest sent = captureSentRequest();
|
||||
assertThat(sent.method()).isEqualTo("POST");
|
||||
assertThat(sent.bodyPublisher()).isPresent();
|
||||
// ofInputStream publishes with an unknown content length (-1).
|
||||
assertThat(sent.bodyPublisher().get().contentLength()).isEqualTo(-1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT also streams the request body via the input-stream publisher")
|
||||
void putStreamsInputStream() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getContentType()).thenReturn(null);
|
||||
when(request.getInputStream())
|
||||
.thenReturn(servletInputStream("raw".getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
service.forward("PUT", "/v1/item/3", request, false);
|
||||
|
||||
HttpRequest sent = captureSentRequest();
|
||||
assertThat(sent.method()).isEqualTo("PUT");
|
||||
assertThat(sent.bodyPublisher().get().contentLength()).isEqualTo(-1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"the streamed body publisher lazily emits the exact request bytes when drained")
|
||||
void streamedBodyContainsRequestBytes() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getContentType()).thenReturn("application/json");
|
||||
when(request.getInputStream())
|
||||
.thenReturn(servletInputStream("hello-body".getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
service.forward("POST", "/v1/chat", request, false);
|
||||
|
||||
HttpRequest sent = captureSentRequest();
|
||||
// The supplier is lazy: getInputStream() is only invoked once the body is consumed.
|
||||
String body = drainBody(sent.bodyPublisher().get());
|
||||
assertThat(body).isEqualTo("hello-body");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"an IOException while opening the request stream surfaces as UncheckedIOException")
|
||||
void inputStreamFailureBecomesUnchecked() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getContentType()).thenReturn("application/json");
|
||||
when(request.getInputStream()).thenThrow(new IOException("stream gone"));
|
||||
|
||||
service.forward("POST", "/v1/chat", request, false);
|
||||
|
||||
// The failure only triggers when the lazy supplier runs at body-drain time.
|
||||
HttpRequest sent = captureSentRequest();
|
||||
assertThatThrownBy(() -> drainBody(sent.bodyPublisher().get()))
|
||||
.isInstanceOf(UncheckedIOException.class)
|
||||
.hasRootCauseInstanceOf(IOException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("response propagation and send delegation")
|
||||
class SendDelegation {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns exactly the response produced by the underlying client")
|
||||
void returnsClientResponse() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
HttpResponse<InputStream> result = service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(result).isSameAs(response);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an IOException from the client propagates to the caller")
|
||||
void clientIoExceptionPropagates() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
|
||||
.thenThrow(new IOException("connection refused"));
|
||||
|
||||
assertThatThrownBy(() -> service.forward("GET", "/v1/x", request, false))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessage("connection refused");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an InterruptedException from the client propagates to the caller")
|
||||
void clientInterruptedExceptionPropagates() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
|
||||
.thenThrow(new InterruptedException("interrupted"));
|
||||
|
||||
assertThatThrownBy(() -> service.forward("GET", "/v1/x", request, false))
|
||||
.isInstanceOf(InterruptedException.class);
|
||||
// Clear the interrupt flag the thrown InterruptedException may have left.
|
||||
Thread.interrupted();
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------------------------
|
||||
|
||||
/** Drain a BodyPublisher to a UTF-8 string, propagating any error the supplier throws. */
|
||||
private static String drainBody(HttpRequest.BodyPublisher publisher) {
|
||||
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
|
||||
java.util.concurrent.atomic.AtomicReference<Throwable> error =
|
||||
new java.util.concurrent.atomic.AtomicReference<>();
|
||||
java.util.concurrent.Flow.Subscriber<java.nio.ByteBuffer> subscriber =
|
||||
new java.util.concurrent.Flow.Subscriber<>() {
|
||||
@Override
|
||||
public void onSubscribe(java.util.concurrent.Flow.Subscription s) {
|
||||
s.request(Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(java.nio.ByteBuffer item) {
|
||||
byte[] chunk = new byte[item.remaining()];
|
||||
item.get(chunk);
|
||||
out.write(chunk, 0, chunk.length);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
error.set(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {}
|
||||
};
|
||||
publisher.subscribe(subscriber);
|
||||
Throwable t = error.get();
|
||||
if (t instanceof RuntimeException re) {
|
||||
throw re;
|
||||
}
|
||||
if (t != null) {
|
||||
throw new RuntimeException(t);
|
||||
}
|
||||
return out.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** Minimal ServletInputStream over a fixed byte array for streaming-body tests. */
|
||||
private static ServletInputStream servletInputStream(byte[] data) {
|
||||
ByteArrayInputStream delegate = new ByteArrayInputStream(data);
|
||||
return new ServletInputStream() {
|
||||
@Override
|
||||
public int read() {
|
||||
return delegate.read();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFinished() {
|
||||
return delegate.available() == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setReadListener(jakarta.servlet.ReadListener readListener) {
|
||||
// no-op: synchronous reads only in tests
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+783
@@ -0,0 +1,783 @@
|
||||
package stirling.software.saas.ai.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.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
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.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpSession;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.saas.ai.model.AiCreateSession;
|
||||
import stirling.software.saas.ai.model.AiCreateSessionStatus;
|
||||
import stirling.software.saas.ai.repository.AiCreateSessionRepository;
|
||||
import stirling.software.saas.security.EnhancedJwtAuthenticationToken;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link AiCreateSessionService}.
|
||||
*
|
||||
* <p>The service is a thin persistence orchestrator over {@link AiCreateSessionRepository} plus a
|
||||
* three-tier user-id resolution chain: {@code UserServiceInterface.getCurrentUsername()} ->
|
||||
* Supabase id from the {@link SecurityContextHolder} authentication -> servlet session-scoped id ->
|
||||
* the {@code "default_user"} fallback. Repository.save is stubbed to echo its argument so the
|
||||
* field-mutation assertions can read back what the service set. SecurityContext and
|
||||
* RequestContextHolder are reset after every test to keep the static thread-local state isolated.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class AiCreateSessionServiceTest {
|
||||
|
||||
@Mock private AiCreateSessionRepository repository;
|
||||
@Mock private UserServiceInterface userService;
|
||||
|
||||
private static final String SUPABASE_ID = "11111111-2222-3333-4444-555555555555";
|
||||
private static final String DEFAULT_USER_ID = "default_user";
|
||||
|
||||
@BeforeEach
|
||||
void echoSave() {
|
||||
// Persistence is a no-op for these unit tests; save() returns the same managed entity so
|
||||
// mutation assertions can read it back.
|
||||
when(repository.save(any(AiCreateSession.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearStatics() {
|
||||
SecurityContextHolder.clearContext();
|
||||
RequestContextHolder.resetRequestAttributes();
|
||||
}
|
||||
|
||||
/** Service with a present (but unstubbed-by-default) UserServiceInterface. */
|
||||
private AiCreateSessionService serviceWithUserService() {
|
||||
return new AiCreateSessionService(repository, Optional.of(userService));
|
||||
}
|
||||
|
||||
/** Service with no UserServiceInterface bean wired (Optional.empty). */
|
||||
private AiCreateSessionService serviceWithoutUserService() {
|
||||
return new AiCreateSessionService(repository, Optional.empty());
|
||||
}
|
||||
|
||||
/** Authenticate the SecurityContext with a Supabase-id-bearing JWT token. */
|
||||
private static void authenticateJwt(String supabaseId) {
|
||||
Map<String, Object> headers = new HashMap<>();
|
||||
headers.put("alg", "RS256");
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("sub", supabaseId);
|
||||
claims.put("email", "user@example.com");
|
||||
Jwt jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(3600), headers, claims);
|
||||
EnhancedJwtAuthenticationToken auth =
|
||||
new EnhancedJwtAuthenticationToken(
|
||||
jwt,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_USER")),
|
||||
"user@example.com",
|
||||
supabaseId);
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
}
|
||||
|
||||
/** Bind a servlet request (optionally with a live HttpSession) to the current thread. */
|
||||
private static MockHttpServletRequest bindRequest(boolean withSession, String sessionId) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
if (withSession) {
|
||||
// (ServletContext, id) ctor fixes the session id so "session:<id>" is deterministic.
|
||||
request.setSession(new MockHttpSession(null, sessionId));
|
||||
}
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
return request;
|
||||
}
|
||||
|
||||
/** A persisted AiCreateSession owned by the given user. */
|
||||
private static AiCreateSession existingSession(String sessionId, String userId) {
|
||||
AiCreateSession session = new AiCreateSession();
|
||||
session.setSessionId(sessionId);
|
||||
session.setUserId(userId);
|
||||
session.setStatus(AiCreateSessionStatus.OUTLINE_PENDING);
|
||||
return session;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// resolveUserId() — three-tier precedence chain
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("resolveUserId precedence")
|
||||
class ResolveUserId {
|
||||
|
||||
@Test
|
||||
@DisplayName("UserServiceInterface username wins over everything else")
|
||||
void userServiceUsernameWins() {
|
||||
// Even with a JWT auth present, the username from the user service takes priority.
|
||||
authenticateJwt(SUPABASE_ID);
|
||||
when(userService.getCurrentUsername()).thenReturn("alice@corp.com");
|
||||
|
||||
assertThat(serviceWithUserService().resolveUserId()).isEqualTo("alice@corp.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank username is ignored and the chain falls through to the JWT id")
|
||||
void blankUsernameFallsThroughToJwt() {
|
||||
authenticateJwt(SUPABASE_ID);
|
||||
when(userService.getCurrentUsername()).thenReturn(" ");
|
||||
|
||||
assertThat(serviceWithUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("anonymousUser username is ignored and the chain falls through")
|
||||
void anonymousUsernameFallsThrough() {
|
||||
authenticateJwt(SUPABASE_ID);
|
||||
when(userService.getCurrentUsername()).thenReturn("anonymousUser");
|
||||
|
||||
assertThat(serviceWithUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null username is ignored and the chain falls through")
|
||||
void nullUsernameFallsThrough() {
|
||||
authenticateJwt(SUPABASE_ID);
|
||||
when(userService.getCurrentUsername()).thenReturn(null);
|
||||
|
||||
assertThat(serviceWithUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a throwing user service is swallowed and the chain falls through")
|
||||
void throwingUserServiceSwallowedAndFallsThrough() {
|
||||
authenticateJwt(SUPABASE_ID);
|
||||
when(userService.getCurrentUsername()).thenThrow(new RuntimeException("boom"));
|
||||
|
||||
assertThat(serviceWithUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("absent user service bean skips tier 1 and uses the JWT id")
|
||||
void absentUserServiceUsesJwt() {
|
||||
authenticateJwt(SUPABASE_ID);
|
||||
|
||||
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unauthenticated 2-arg token (isAuthenticated=false) is skipped")
|
||||
void unauthenticatedTokenSkipped() {
|
||||
// The 2-arg UsernamePasswordAuthenticationToken ctor leaves isAuthenticated()=false,
|
||||
// so the JWT branch is bypassed and we fall through to the default.
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(new UsernamePasswordAuthenticationToken("bob", "creds"));
|
||||
|
||||
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(DEFAULT_USER_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("authenticated principal id is used via the generic getName() fallback")
|
||||
void authenticatedPrincipalNameUsed() {
|
||||
// A non-JWT authenticated token: extractSupabaseId falls back to getName().
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
"carol",
|
||||
"creds",
|
||||
List.of(new SimpleGrantedAuthority("ROLE_USER"))));
|
||||
|
||||
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo("carol");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("authenticated 'anonymousUser' name is rejected, chain falls through")
|
||||
void anonymousAuthNameRejected() {
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
"anonymousUser",
|
||||
"creds",
|
||||
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
|
||||
|
||||
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(DEFAULT_USER_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no auth + a live HttpSession yields a session-scoped id")
|
||||
void sessionScopedIdWhenNoAuth() {
|
||||
bindRequest(true, "sess-abc");
|
||||
|
||||
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo("session:sess-abc");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no auth + a request without a session falls through to default")
|
||||
void noSessionFallsThroughToDefault() {
|
||||
bindRequest(false, null);
|
||||
|
||||
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(DEFAULT_USER_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no user service, no auth, no request context -> default_user")
|
||||
void defaultUserWhenNothingResolves() {
|
||||
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(DEFAULT_USER_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("JWT id is preferred over an available session-scoped id")
|
||||
void jwtPreferredOverSession() {
|
||||
authenticateJwt(SUPABASE_ID);
|
||||
bindRequest(true, "sess-xyz");
|
||||
|
||||
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// createSession
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("createSession")
|
||||
class CreateSession {
|
||||
|
||||
@Test
|
||||
@DisplayName("populates every field, generates a session id, and persists once")
|
||||
void populatesAndSaves() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSessionService service = serviceWithUserService();
|
||||
|
||||
AiCreateSession out =
|
||||
service.createSession(
|
||||
"my prompt", "report", "tmpl-1", "\\documentclass{}", "preview");
|
||||
|
||||
assertThat(out.getUserId()).isEqualTo("owner");
|
||||
assertThat(out.getDocType()).isEqualTo("report");
|
||||
assertThat(out.getTemplateId()).isEqualTo("tmpl-1");
|
||||
assertThat(out.getTemplateTex()).isEqualTo("\\documentclass{}");
|
||||
assertThat(out.getPreviewTex()).isEqualTo("preview");
|
||||
assertThat(out.getPromptInitial()).isEqualTo("my prompt");
|
||||
assertThat(out.getPromptLatest()).isEqualTo("my prompt");
|
||||
assertThat(out.isOutlineApproved()).isFalse();
|
||||
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.OUTLINE_PENDING);
|
||||
// A random UUID session id was generated.
|
||||
assertThat(out.getSessionId()).isNotBlank();
|
||||
assertThat(UUID.fromString(out.getSessionId())).isNotNull();
|
||||
verify(repository).save(out);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two sessions get distinct generated ids")
|
||||
void distinctSessionIds() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSessionService service = serviceWithUserService();
|
||||
|
||||
AiCreateSession a = service.createSession("p", null, null, null, null);
|
||||
AiCreateSession b = service.createSession("p", null, null, null, null);
|
||||
|
||||
assertThat(a.getSessionId()).isNotEqualTo(b.getSessionId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("uses default_user when nothing else resolves the identity")
|
||||
void usesDefaultUser() {
|
||||
AiCreateSession out =
|
||||
serviceWithoutUserService().createSession("p", "doc", "t", "tex", "prev");
|
||||
|
||||
assertThat(out.getUserId()).isEqualTo(DEFAULT_USER_ID);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// getSession / getSessionForCurrentUser
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("getSession / getSessionForCurrentUser")
|
||||
class GetSession {
|
||||
|
||||
@Test
|
||||
@DisplayName("getSession returns the persisted row")
|
||||
void getSessionReturnsRow() {
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(serviceWithoutUserService().getSession("s1")).isSameAs(row);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getSession throws 404 when the row is missing")
|
||||
void getSessionMissingThrows404() {
|
||||
when(repository.findById("nope")).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> serviceWithoutUserService().getSession("nope"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
ex ->
|
||||
assertThat(((ResponseStatusException) ex).getStatusCode())
|
||||
.isEqualTo(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getSessionForCurrentUser returns the row when the owner matches")
|
||||
void ownerMatchReturnsRow() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(serviceWithUserService().getSessionForCurrentUser("s1")).isSameAs(row);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getSessionForCurrentUser hides another user's session behind a 404")
|
||||
void foreignOwnerThrows404() {
|
||||
when(userService.getCurrentUsername()).thenReturn("intruder");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThatThrownBy(() -> serviceWithUserService().getSessionForCurrentUser("s1"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
ex ->
|
||||
assertThat(((ResponseStatusException) ex).getStatusCode())
|
||||
.isEqualTo(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// updateOutline
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateOutline")
|
||||
class UpdateOutline {
|
||||
|
||||
@Test
|
||||
@DisplayName("sets outline text, filename, constraints, approval flag and APPROVED status")
|
||||
void fullUpdate() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out =
|
||||
serviceWithUserService()
|
||||
.updateOutline("s1", "the outline", "outline.tex", "be brief");
|
||||
|
||||
assertThat(out.getOutlineText()).isEqualTo("the outline");
|
||||
assertThat(out.getOutlineFilename()).isEqualTo("outline.tex");
|
||||
assertThat(out.getOutlineConstraints()).isEqualTo("be brief");
|
||||
assertThat(out.isOutlineApproved()).isTrue();
|
||||
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.OUTLINE_APPROVED);
|
||||
verify(repository).save(row);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank filename is not applied; null constraints are left untouched")
|
||||
void blankFilenameAndNullConstraintsIgnored() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
row.setOutlineFilename("keep.tex");
|
||||
row.setOutlineConstraints("keep-constraints");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out = serviceWithUserService().updateOutline("s1", "txt", " ", null);
|
||||
|
||||
assertThat(out.getOutlineFilename()).isEqualTo("keep.tex");
|
||||
assertThat(out.getOutlineConstraints()).isEqualTo("keep-constraints");
|
||||
// Still approved + status flipped even with skipped optional fields.
|
||||
assertThat(out.isOutlineApproved()).isTrue();
|
||||
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.OUTLINE_APPROVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty-string constraints ARE applied (only null is skipped)")
|
||||
void emptyConstraintsApplied() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
row.setOutlineConstraints("old");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out = serviceWithUserService().updateOutline("s1", "t", "f", "");
|
||||
|
||||
assertThat(out.getOutlineConstraints()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a foreign session 404s before any mutation or save")
|
||||
void foreignSessionBlocked() {
|
||||
when(userService.getCurrentUsername()).thenReturn("intruder");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThatThrownBy(() -> serviceWithUserService().updateOutline("s1", "t", "f", "c"))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
assertThat(row.getOutlineText()).isNull();
|
||||
verify(repository, never()).save(any());
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// updateDraftSections
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("updateDraftSections stores sections and flips status to DRAFT_READY")
|
||||
void updateDraftSections() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out = serviceWithUserService().updateDraftSections("s1", "section json");
|
||||
|
||||
assertThat(out.getDraftSections()).isEqualTo("section json");
|
||||
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.DRAFT_READY);
|
||||
verify(repository).save(row);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// updateTemplate
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("updateTemplate")
|
||||
class UpdateTemplate {
|
||||
|
||||
@Test
|
||||
@DisplayName("updates docType and templateId when both are non-blank")
|
||||
void updatesBoth() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
row.setDocType("old-doc");
|
||||
row.setTemplateId("old-tmpl");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out =
|
||||
serviceWithUserService().updateTemplate("s1", "new-doc", "new-tmpl");
|
||||
|
||||
assertThat(out.getDocType()).isEqualTo("new-doc");
|
||||
assertThat(out.getTemplateId()).isEqualTo("new-tmpl");
|
||||
verify(repository).save(row);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null/blank inputs leave the existing template untouched")
|
||||
void blankInputsKeepExisting() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
row.setDocType("old-doc");
|
||||
row.setTemplateId("old-tmpl");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out = serviceWithUserService().updateTemplate("s1", null, " ");
|
||||
|
||||
assertThat(out.getDocType()).isEqualTo("old-doc");
|
||||
assertThat(out.getTemplateId()).isEqualTo("old-tmpl");
|
||||
// Still persists (no-op save) — the method always saves.
|
||||
verify(repository).save(row);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// reprompt
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("reprompt resets all derived artifacts and re-enters OUTLINE_PENDING")
|
||||
void reprompt() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
row.setPromptLatest("old prompt");
|
||||
row.setOutlineText("old outline");
|
||||
row.setOutlineFilename("old.tex");
|
||||
row.setOutlineApproved(true);
|
||||
row.setOutlineConstraints("old constraints");
|
||||
row.setDraftSections("old draft");
|
||||
row.setPolishedLatex("old latex");
|
||||
row.setPdfUrl("https://old/url");
|
||||
row.setStatus(AiCreateSessionStatus.POLISHED_READY);
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out = serviceWithUserService().reprompt("s1", "fresh prompt");
|
||||
|
||||
assertThat(out.getPromptLatest()).isEqualTo("fresh prompt");
|
||||
assertThat(out.getOutlineText()).isNull();
|
||||
assertThat(out.getOutlineFilename()).isNull();
|
||||
assertThat(out.isOutlineApproved()).isFalse();
|
||||
assertThat(out.getOutlineConstraints()).isNull();
|
||||
assertThat(out.getDraftSections()).isNull();
|
||||
assertThat(out.getPolishedLatex()).isNull();
|
||||
assertThat(out.getPdfUrl()).isNull();
|
||||
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.OUTLINE_PENDING);
|
||||
verify(repository).save(row);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// deleteSessionForCurrentUser
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("deleteSessionForCurrentUser")
|
||||
class DeleteSession {
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes the owner's session")
|
||||
void deletesOwnerSession() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
serviceWithUserService().deleteSessionForCurrentUser("s1");
|
||||
|
||||
verify(repository).delete(row);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a foreign session 404s and is never deleted")
|
||||
void foreignSessionNotDeleted() {
|
||||
when(userService.getCurrentUsername()).thenReturn("intruder");
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThatThrownBy(() -> serviceWithUserService().deleteSessionForCurrentUser("s1"))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verify(repository, never()).delete(any());
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// applyInternalUpdate — null-coalescing partial update, no ownership check
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("applyInternalUpdate")
|
||||
class ApplyInternalUpdate {
|
||||
|
||||
@Test
|
||||
@DisplayName("applies every non-null field including outlineApproved=false")
|
||||
void appliesAllFields() {
|
||||
// No ownership check on the internal path: it uses getSession, not the per-user guard.
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
row.setOutlineApproved(true);
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out =
|
||||
serviceWithoutUserService()
|
||||
.applyInternalUpdate(
|
||||
"s1",
|
||||
"outline",
|
||||
"o.tex",
|
||||
Boolean.FALSE,
|
||||
"constraints",
|
||||
"draft",
|
||||
"latex",
|
||||
"https://pdf/url",
|
||||
"doc",
|
||||
"tmpl",
|
||||
AiCreateSessionStatus.POLISHED_READY);
|
||||
|
||||
assertThat(out.getOutlineText()).isEqualTo("outline");
|
||||
assertThat(out.getOutlineFilename()).isEqualTo("o.tex");
|
||||
// Boolean.FALSE is non-null so it IS applied, flipping the prior true.
|
||||
assertThat(out.isOutlineApproved()).isFalse();
|
||||
assertThat(out.getOutlineConstraints()).isEqualTo("constraints");
|
||||
assertThat(out.getDraftSections()).isEqualTo("draft");
|
||||
assertThat(out.getPolishedLatex()).isEqualTo("latex");
|
||||
assertThat(out.getPdfUrl()).isEqualTo("https://pdf/url");
|
||||
assertThat(out.getDocType()).isEqualTo("doc");
|
||||
assertThat(out.getTemplateId()).isEqualTo("tmpl");
|
||||
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.POLISHED_READY);
|
||||
verify(repository).save(row);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("all-null arguments leave the row untouched but still persist")
|
||||
void allNullLeavesUntouched() {
|
||||
AiCreateSession row = existingSession("s1", "owner");
|
||||
row.setOutlineText("keep-outline");
|
||||
row.setDocType("keep-doc");
|
||||
row.setStatus(AiCreateSessionStatus.DRAFT_READY);
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out =
|
||||
serviceWithoutUserService()
|
||||
.applyInternalUpdate(
|
||||
"s1", null, null, null, null, null, null, null, null, null,
|
||||
null);
|
||||
|
||||
assertThat(out.getOutlineText()).isEqualTo("keep-outline");
|
||||
assertThat(out.getDocType()).isEqualTo("keep-doc");
|
||||
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.DRAFT_READY);
|
||||
verify(repository).save(row);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("internal path 404s when the session does not exist")
|
||||
void missingSession404() {
|
||||
when(repository.findById("ghost")).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
serviceWithoutUserService()
|
||||
.applyInternalUpdate(
|
||||
"ghost", "x", null, null, null, null, null,
|
||||
null, null, null, null))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("internal path ignores ownership — updates a row owned by another user")
|
||||
void ignoresOwnership() {
|
||||
// applyInternalUpdate uses getSession (not getSessionForCurrentUser); current identity
|
||||
// is irrelevant. Confirm a non-matching identity still updates the row.
|
||||
authenticateJwt(SUPABASE_ID);
|
||||
AiCreateSession row = existingSession("s1", "someone-else");
|
||||
when(repository.findById("s1")).thenReturn(Optional.of(row));
|
||||
|
||||
AiCreateSession out =
|
||||
serviceWithoutUserService()
|
||||
.applyInternalUpdate(
|
||||
"s1",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"https://done/pdf",
|
||||
null,
|
||||
null,
|
||||
AiCreateSessionStatus.SAVED);
|
||||
|
||||
assertThat(out.getPdfUrl()).isEqualTo("https://done/pdf");
|
||||
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.SAVED);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// list* — delegation to the right repository finder for the resolved user
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("listing methods")
|
||||
class Listing {
|
||||
|
||||
@Test
|
||||
@DisplayName("no-arg list delegates to findByUserIdOrderByUpdatedAtDesc(userId)")
|
||||
void listNoArg() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
List<AiCreateSession> expected = List.of(existingSession("s1", "owner"));
|
||||
when(repository.findByUserIdOrderByUpdatedAtDesc("owner")).thenReturn(expected);
|
||||
|
||||
assertThat(serviceWithUserService().listSessionsForCurrentUser()).isSameAs(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("paged list delegates with the pageable for the resolved user")
|
||||
void listPaged() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
Pageable pageable = PageRequest.of(0, 20);
|
||||
List<AiCreateSession> expected = List.of(existingSession("s1", "owner"));
|
||||
when(repository.findByUserIdOrderByUpdatedAtDesc("owner", pageable))
|
||||
.thenReturn(expected);
|
||||
|
||||
assertThat(serviceWithUserService().listSessionsForCurrentUser(pageable))
|
||||
.isSameAs(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("includeDrafts=true returns the all-sessions finder")
|
||||
void listIncludeDraftsTrue() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
Pageable pageable = PageRequest.of(0, 10);
|
||||
List<AiCreateSession> expected = List.of(existingSession("s1", "owner"));
|
||||
when(repository.findByUserIdOrderByUpdatedAtDesc("owner", pageable))
|
||||
.thenReturn(expected);
|
||||
|
||||
assertThat(serviceWithUserService().listSessionsForCurrentUser(pageable, true))
|
||||
.isSameAs(expected);
|
||||
verify(repository, never())
|
||||
.findByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("includeDrafts=false returns only sessions with a non-null pdfUrl")
|
||||
void listIncludeDraftsFalse() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
Pageable pageable = PageRequest.of(0, 10);
|
||||
List<AiCreateSession> expected = List.of(existingSession("s1", "owner"));
|
||||
when(repository.findByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc("owner", pageable))
|
||||
.thenReturn(expected);
|
||||
|
||||
assertThat(serviceWithUserService().listSessionsForCurrentUser(pageable, false))
|
||||
.isSameAs(expected);
|
||||
verify(repository, never())
|
||||
.findByUserIdOrderByUpdatedAtDesc(eq("owner"), any(Pageable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("summary list includeDrafts=true uses the all-summaries projection finder")
|
||||
void summariesIncludeDraftsTrue() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
Pageable pageable = PageRequest.of(0, 10);
|
||||
List<AiCreateSessionRepository.AiCreateSessionSummaryProjection> expected = List.of();
|
||||
when(repository.findSummariesByUserIdOrderByUpdatedAtDesc("owner", pageable))
|
||||
.thenReturn(expected);
|
||||
|
||||
assertThat(serviceWithUserService().listSessionSummariesForCurrentUser(pageable, true))
|
||||
.isSameAs(expected);
|
||||
verify(repository, never())
|
||||
.findSummariesByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("summary list includeDrafts=false uses the pdf-only summaries finder")
|
||||
void summariesIncludeDraftsFalse() {
|
||||
when(userService.getCurrentUsername()).thenReturn("owner");
|
||||
Pageable pageable = PageRequest.of(0, 10);
|
||||
List<AiCreateSessionRepository.AiCreateSessionSummaryProjection> expected = List.of();
|
||||
when(repository.findSummariesByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(
|
||||
"owner", pageable))
|
||||
.thenReturn(expected);
|
||||
|
||||
assertThat(serviceWithUserService().listSessionSummariesForCurrentUser(pageable, false))
|
||||
.isSameAs(expected);
|
||||
verify(repository, never()).findSummariesByUserIdOrderByUpdatedAtDesc(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("listing for an unidentified caller queries the default_user partition")
|
||||
void listForDefaultUser() {
|
||||
List<AiCreateSession> expected = List.of();
|
||||
when(repository.findByUserIdOrderByUpdatedAtDesc(DEFAULT_USER_ID)).thenReturn(expected);
|
||||
|
||||
assertThat(serviceWithoutUserService().listSessionsForCurrentUser()).isSameAs(expected);
|
||||
ArgumentCaptor<String> userIdCaptor = ArgumentCaptor.forClass(String.class);
|
||||
verify(repository).findByUserIdOrderByUpdatedAtDesc(userIdCaptor.capture());
|
||||
assertThat(userIdCaptor.getValue()).isEqualTo(DEFAULT_USER_ID);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
package stirling.software.saas.ai.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.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import jakarta.servlet.ServletInputStream;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.Part;
|
||||
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link AiProxyService}.
|
||||
*
|
||||
* <p>The service forwards HTTP requests to an AI backend. It constructs its own {@link HttpClient}
|
||||
* internally (no constructor injection), so each test swaps in a mocked client via {@link
|
||||
* ReflectionTestUtils} and captures the outgoing {@link HttpRequest} to assert on URL, method and
|
||||
* headers. All collaborators ({@link HttpServletRequest}, {@link UserService}, {@link
|
||||
* UserRepository}) are mocked; no Spring context, DB or real network is involved.
|
||||
*
|
||||
* <p>Header semantics under test: Authorization is forwarded when present/non-blank; X-API-KEY is
|
||||
* taken from the request header first and otherwise resolved from the authenticated user; Accept is
|
||||
* overridden to {@code text/event-stream} when SSE is requested; the target URL is assembled from
|
||||
* the configured base URL, the path and the query string.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class AiProxyServiceTest {
|
||||
|
||||
private static final String BASE_URL = "http://ai-backend:5001";
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private UserService userService;
|
||||
@Mock private HttpServletRequest request;
|
||||
@Mock private HttpClient httpClient;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private final HttpResponse<InputStream> response =
|
||||
(HttpResponse<InputStream>) org.mockito.Mockito.mock(HttpResponse.class);
|
||||
|
||||
private AiProxyService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
service = new AiProxyService(BASE_URL, userRepository, userService);
|
||||
// Swap the internally-built client for our mock so no real network call happens.
|
||||
ReflectionTestUtils.setField(service, "httpClient", httpClient);
|
||||
// Default: the mocked client returns our stub response for any send().
|
||||
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
|
||||
.thenReturn(response);
|
||||
}
|
||||
|
||||
/** Capture the single HttpRequest the service hands to the client. */
|
||||
private HttpRequest captureSentRequest() throws Exception {
|
||||
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
|
||||
verify(httpClient).send(captor.capture(), any(HttpResponse.BodyHandler.class));
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private static String header(HttpRequest req, String name) {
|
||||
return req.headers().firstValue(name).orElse(null);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("target URL assembly")
|
||||
class UrlAssembly {
|
||||
|
||||
@Test
|
||||
@DisplayName("joins base + leading-slash path + query string")
|
||||
void joinsBasePathAndQuery() throws Exception {
|
||||
when(request.getQueryString()).thenReturn("model=foo&n=2");
|
||||
|
||||
service.forward("GET", "/v1/chat", request, false);
|
||||
|
||||
HttpRequest sent = captureSentRequest();
|
||||
assertThat(sent.uri())
|
||||
.isEqualTo(URI.create("http://ai-backend:5001/v1/chat?model=foo&n=2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("prepends a slash when the path lacks one")
|
||||
void prependsMissingSlash() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
service.forward("GET", "v1/health", request, false);
|
||||
|
||||
assertThat(captureSentRequest().uri())
|
||||
.isEqualTo(URI.create("http://ai-backend:5001/v1/health"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank query string is ignored")
|
||||
void blankQueryIgnored() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(" ");
|
||||
|
||||
service.forward("GET", "/v1/ping", request, false);
|
||||
|
||||
assertThat(captureSentRequest().uri())
|
||||
.isEqualTo(URI.create("http://ai-backend:5001/v1/ping"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("trailing slash on the configured base URL is trimmed")
|
||||
void trimsTrailingSlashOnBase() throws Exception {
|
||||
AiProxyService svc =
|
||||
new AiProxyService("http://ai-backend:5001/", userRepository, userService);
|
||||
ReflectionTestUtils.setField(svc, "httpClient", httpClient);
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
svc.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().uri())
|
||||
.isEqualTo(URI.create("http://ai-backend:5001/v1/x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("surrounding whitespace on the base URL is trimmed")
|
||||
void trimsWhitespaceOnBase() throws Exception {
|
||||
AiProxyService svc =
|
||||
new AiProxyService(" http://ai-backend:5001 ", userRepository, userService);
|
||||
ReflectionTestUtils.setField(svc, "httpClient", httpClient);
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
svc.forward("GET", "/v1/y", request, false);
|
||||
|
||||
assertThat(captureSentRequest().uri())
|
||||
.isEqualTo(URI.create("http://ai-backend:5001/v1/y"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank base URL falls back to the localhost default")
|
||||
void blankBaseUrlFallsBackToDefault() throws Exception {
|
||||
AiProxyService svc = new AiProxyService(" ", userRepository, userService);
|
||||
ReflectionTestUtils.setField(svc, "httpClient", httpClient);
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
svc.forward("GET", "/v1/z", request, false);
|
||||
|
||||
assertThat(captureSentRequest().uri())
|
||||
.isEqualTo(URI.create("http://localhost:5001/v1/z"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null base URL falls back to the localhost default")
|
||||
void nullBaseUrlFallsBackToDefault() throws Exception {
|
||||
AiProxyService svc = new AiProxyService(null, userRepository, userService);
|
||||
ReflectionTestUtils.setField(svc, "httpClient", httpClient);
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
svc.forward("GET", "/v1/q", request, false);
|
||||
|
||||
assertThat(captureSentRequest().uri())
|
||||
.isEqualTo(URI.create("http://localhost:5001/v1/q"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Authorization header forwarding")
|
||||
class AuthorizationForwarding {
|
||||
|
||||
@Test
|
||||
@DisplayName("forwards a present Authorization header verbatim")
|
||||
void forwardsPresentAuth() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("Authorization")).thenReturn("Bearer abc.def");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(header(captureSentRequest(), "Authorization")).isEqualTo("Bearer abc.def");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("omits the header when Authorization is null")
|
||||
void omitsWhenNull() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("Authorization")).thenReturn(null);
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("Authorization")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("omits the header when Authorization is blank")
|
||||
void omitsWhenBlank() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("Authorization")).thenReturn(" ");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("Authorization")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("X-API-KEY resolution")
|
||||
class ApiKeyResolution {
|
||||
|
||||
@Test
|
||||
@DisplayName("uses the X-API-KEY header from the request when present (no user lookup)")
|
||||
void usesRequestHeaderAndSkipsUserLookup() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("X-API-KEY")).thenReturn("req-key-123");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("req-key-123");
|
||||
// Header short-circuits the authenticated-user fallback entirely.
|
||||
verifyNoInteractions(userService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to the authenticated user's API key when the header is absent")
|
||||
void fallsBackToUserApiKey() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("X-API-KEY")).thenReturn(null);
|
||||
when(userService.getCurrentUsername()).thenReturn("alice");
|
||||
when(userService.getApiKeyForUser("alice")).thenReturn("user-key-xyz");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("user-key-xyz");
|
||||
verify(userService).getApiKeyForUser("alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to the user key when the header is blank")
|
||||
void blankHeaderTriggersFallback() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("X-API-KEY")).thenReturn(" ");
|
||||
when(userService.getCurrentUsername()).thenReturn("bob");
|
||||
when(userService.getApiKeyForUser("bob")).thenReturn("bob-key");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("bob-key");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no X-API-KEY header set when there is no authenticated user")
|
||||
void noUser_noHeader() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("X-API-KEY")).thenReturn(null);
|
||||
when(userService.getCurrentUsername()).thenReturn(null);
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
|
||||
// Username was null/blank, so the key lookup is never attempted.
|
||||
verify(userService, never()).getApiKeyForUser(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank username from the security context yields no header")
|
||||
void blankUsername_noHeader() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("X-API-KEY")).thenReturn(null);
|
||||
when(userService.getCurrentUsername()).thenReturn(" ");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
|
||||
verify(userService, never()).getApiKeyForUser(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a resolved-but-blank user key is not forwarded")
|
||||
void blankResolvedKey_noHeader() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("X-API-KEY")).thenReturn(null);
|
||||
when(userService.getCurrentUsername()).thenReturn("carol");
|
||||
when(userService.getApiKeyForUser("carol")).thenReturn("");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an exception while resolving the user key is swallowed; no header forwarded")
|
||||
void userKeyLookupThrows_isSwallowed() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("X-API-KEY")).thenReturn(null);
|
||||
when(userService.getCurrentUsername()).thenReturn("dave");
|
||||
when(userService.getApiKeyForUser("dave"))
|
||||
.thenThrow(new RuntimeException("key store offline"));
|
||||
|
||||
// Must not propagate: extractUserApiKey() catches and returns null.
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Accept header handling")
|
||||
class AcceptHandling {
|
||||
|
||||
@Test
|
||||
@DisplayName("acceptEventStream overrides any inbound Accept with text/event-stream")
|
||||
void eventStreamOverrides() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("Accept")).thenReturn("application/json");
|
||||
|
||||
service.forward("GET", "/v1/stream", request, true);
|
||||
|
||||
assertThat(header(captureSentRequest(), "Accept")).isEqualTo("text/event-stream");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("event stream is requested even with no inbound Accept header")
|
||||
void eventStreamWithoutInboundAccept() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("Accept")).thenReturn(null);
|
||||
|
||||
service.forward("GET", "/v1/stream", request, true);
|
||||
|
||||
assertThat(header(captureSentRequest(), "Accept")).isEqualTo("text/event-stream");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("passes a non-stream Accept header through unchanged")
|
||||
void passesInboundAcceptThrough() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("Accept")).thenReturn("application/json");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(header(captureSentRequest(), "Accept")).isEqualTo("application/json");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no Accept header set when inbound Accept is absent and SSE not requested")
|
||||
void noAcceptWhenAbsentAndNotStreaming() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("Accept")).thenReturn(null);
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("Accept")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank inbound Accept is not forwarded when SSE not requested")
|
||||
void blankAcceptNotForwarded() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getHeader("Accept")).thenReturn(" ");
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("Accept")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("HTTP method and body publisher selection")
|
||||
class MethodAndBody {
|
||||
|
||||
@Test
|
||||
@DisplayName("GET sends no body and never reads the request input stream")
|
||||
void getHasNoBody() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
HttpRequest sent = captureSentRequest();
|
||||
assertThat(sent.method()).isEqualTo("GET");
|
||||
assertThat(sent.bodyPublisher()).isPresent();
|
||||
assertThat(sent.bodyPublisher().get().contentLength()).isZero();
|
||||
// GET/DELETE short-circuit before touching the body.
|
||||
verify(request, never()).getInputStream();
|
||||
verify(request, never()).getParts();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DELETE sends no body and never reads the request input stream")
|
||||
void deleteHasNoBody() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
service.forward("DELETE", "/v1/item/9", request, false);
|
||||
|
||||
HttpRequest sent = captureSentRequest();
|
||||
assertThat(sent.method()).isEqualTo("DELETE");
|
||||
assertThat(sent.bodyPublisher().get().contentLength()).isZero();
|
||||
verify(request, never()).getInputStream();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("method name matching is case-insensitive for the no-body branch")
|
||||
void lowercaseGetStillNoBody() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
service.forward("get", "/v1/x", request, false);
|
||||
|
||||
HttpRequest sent = captureSentRequest();
|
||||
// Lowercase still routes through the GET/DELETE no-body branch.
|
||||
assertThat(sent.method()).isEqualToIgnoringCase("get");
|
||||
assertThat(sent.bodyPublisher().get().contentLength()).isZero();
|
||||
verify(request, never()).getInputStream();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST with a plain content type streams the request input stream as the body")
|
||||
void postStreamsInputStream() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getContentType()).thenReturn("application/json");
|
||||
ServletInputStream sis =
|
||||
servletInputStream("{\"x\":1}".getBytes(StandardCharsets.UTF_8));
|
||||
when(request.getInputStream()).thenReturn(sis);
|
||||
|
||||
service.forward("POST", "/v1/chat", request, false);
|
||||
|
||||
HttpRequest sent = captureSentRequest();
|
||||
assertThat(sent.method()).isEqualTo("POST");
|
||||
// ofInputStream publishes with an unknown length (-1).
|
||||
assertThat(sent.bodyPublisher()).isPresent();
|
||||
// Inbound Content-Type is propagated since the body publisher provides none.
|
||||
assertThat(header(sent, "Content-Type")).isEqualTo("application/json");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST with no inbound content type sets no Content-Type header")
|
||||
void postWithoutContentType() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getContentType()).thenReturn(null);
|
||||
when(request.getInputStream())
|
||||
.thenReturn(servletInputStream("raw".getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
service.forward("POST", "/v1/chat", request, false);
|
||||
|
||||
assertThat(captureSentRequest().headers().firstValue("Content-Type")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("multipart/form-data re-encoding")
|
||||
class Multipart {
|
||||
|
||||
@Test
|
||||
@DisplayName("re-encodes parts and sets a generated multipart boundary Content-Type")
|
||||
void reencodesMultipartBody() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getContentType()).thenReturn("multipart/form-data; boundary=inbound");
|
||||
|
||||
Part field = textPart("prompt", "hello world");
|
||||
Part file = filePart("file", "doc.pdf", "application/pdf", "PDF-BYTES");
|
||||
when(request.getParts()).thenReturn(List.of(field, file));
|
||||
|
||||
service.forward("POST", "/v1/upload", request, false);
|
||||
|
||||
HttpRequest sent = captureSentRequest();
|
||||
String contentType = header(sent, "Content-Type");
|
||||
assertThat(contentType).startsWith("multipart/form-data; boundary=----spdf-");
|
||||
// A fresh boundary is generated rather than reusing the inbound one.
|
||||
assertThat(contentType).doesNotContain("inbound");
|
||||
// Body has a known length (ofByteArray), unlike the streamed-input branch.
|
||||
assertThat(sent.bodyPublisher()).isPresent();
|
||||
assertThat(sent.bodyPublisher().get().contentLength()).isPositive();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the generated boundary in the header matches the one used in the body bytes")
|
||||
void boundaryHeaderMatchesBody() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getContentType()).thenReturn("multipart/form-data");
|
||||
Part textPart = textPart("k", "v");
|
||||
when(request.getParts()).thenReturn(List.of(textPart));
|
||||
|
||||
service.forward("POST", "/v1/upload", request, false);
|
||||
|
||||
HttpRequest sent = captureSentRequest();
|
||||
String contentType = header(sent, "Content-Type");
|
||||
String boundary =
|
||||
contentType.substring(contentType.indexOf("boundary=") + "boundary=".length());
|
||||
|
||||
String body = drainBody(sent.bodyPublisher().get());
|
||||
assertThat(body).contains("--" + boundary);
|
||||
assertThat(body).contains("--" + boundary + "--");
|
||||
assertThat(body).contains("Content-Disposition: form-data; name=\"k\"").contains("v");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a file part renders a filename in its Content-Disposition")
|
||||
void filePartRendersFilename() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getContentType()).thenReturn("multipart/form-data");
|
||||
Part filePart = filePart("file", "a.pdf", "application/pdf", "DATA");
|
||||
when(request.getParts()).thenReturn(List.of(filePart));
|
||||
|
||||
service.forward("POST", "/v1/upload", request, false);
|
||||
|
||||
String body = drainBody(captureSentRequest().bodyPublisher().get());
|
||||
assertThat(body)
|
||||
.contains("Content-Disposition: form-data; name=\"file\"; filename=\"a.pdf\"")
|
||||
.contains("Content-Type: application/pdf")
|
||||
.contains("DATA");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an empty parts collection still produces a valid closing boundary")
|
||||
void emptyPartsClosesBoundary() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getContentType()).thenReturn("multipart/form-data");
|
||||
when(request.getParts()).thenReturn(List.of());
|
||||
|
||||
service.forward("POST", "/v1/upload", request, false);
|
||||
|
||||
HttpRequest sent = captureSentRequest();
|
||||
String contentType = header(sent, "Content-Type");
|
||||
String boundary =
|
||||
contentType.substring(contentType.indexOf("boundary=") + "boundary=".length());
|
||||
// Closing delimiter line + the trailing empty writeLine each append CRLF.
|
||||
assertThat(drainBody(sent.bodyPublisher().get()))
|
||||
.isEqualTo("--" + boundary + "--\r\n\r\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a getParts() failure is surfaced as IOException")
|
||||
void getPartsFailureBecomesIoException() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(request.getContentType()).thenReturn("multipart/form-data");
|
||||
when(request.getParts())
|
||||
.thenThrow(new jakarta.servlet.ServletException("bad multipart"));
|
||||
|
||||
assertThatThrownBy(() -> service.forward("POST", "/v1/upload", request, false))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("Failed to proxy multipart request");
|
||||
// Failed before reaching the client: send() is never invoked.
|
||||
verify(httpClient, never())
|
||||
.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("response propagation and send delegation")
|
||||
class SendDelegation {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns exactly the response produced by the underlying client")
|
||||
void returnsClientResponse() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
|
||||
HttpResponse<InputStream> result = service.forward("GET", "/v1/x", request, false);
|
||||
|
||||
assertThat(result).isSameAs(response);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an IOException from the client propagates to the caller")
|
||||
void clientIoExceptionPropagates() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
|
||||
.thenThrow(new IOException("connection refused"));
|
||||
|
||||
assertThatThrownBy(() -> service.forward("GET", "/v1/x", request, false))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessage("connection refused");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an InterruptedException from the client propagates to the caller")
|
||||
void clientInterruptedExceptionPropagates() throws Exception {
|
||||
when(request.getQueryString()).thenReturn(null);
|
||||
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
|
||||
.thenThrow(new InterruptedException("interrupted"));
|
||||
|
||||
assertThatThrownBy(() -> service.forward("GET", "/v1/x", request, false))
|
||||
.isInstanceOf(InterruptedException.class);
|
||||
// Clear the interrupt flag the thrown InterruptedException may have left.
|
||||
Thread.interrupted();
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------------------------
|
||||
|
||||
private static Part textPart(String name, String value) throws IOException {
|
||||
Part p = org.mockito.Mockito.mock(Part.class);
|
||||
when(p.getName()).thenReturn(name);
|
||||
when(p.getSubmittedFileName()).thenReturn(null);
|
||||
when(p.getContentType()).thenReturn(null);
|
||||
when(p.getInputStream())
|
||||
.thenReturn(new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8)));
|
||||
return p;
|
||||
}
|
||||
|
||||
private static Part filePart(String name, String filename, String contentType, String value)
|
||||
throws IOException {
|
||||
Part p = org.mockito.Mockito.mock(Part.class);
|
||||
when(p.getName()).thenReturn(name);
|
||||
when(p.getSubmittedFileName()).thenReturn(filename);
|
||||
when(p.getContentType()).thenReturn(contentType);
|
||||
when(p.getInputStream())
|
||||
.thenReturn(new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8)));
|
||||
return p;
|
||||
}
|
||||
|
||||
private static String drainBody(HttpRequest.BodyPublisher publisher) {
|
||||
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
|
||||
java.util.concurrent.Flow.Subscriber<java.nio.ByteBuffer> subscriber =
|
||||
new java.util.concurrent.Flow.Subscriber<>() {
|
||||
@Override
|
||||
public void onSubscribe(java.util.concurrent.Flow.Subscription s) {
|
||||
s.request(Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(java.nio.ByteBuffer item) {
|
||||
byte[] chunk = new byte[item.remaining()];
|
||||
item.get(chunk);
|
||||
out.write(chunk, 0, chunk.length);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
throw new RuntimeException(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {}
|
||||
};
|
||||
publisher.subscribe(subscriber);
|
||||
return out.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** Minimal ServletInputStream over a fixed byte array for streaming-body tests. */
|
||||
private static ServletInputStream servletInputStream(byte[] data) {
|
||||
ByteArrayInputStream delegate = new ByteArrayInputStream(data);
|
||||
return new ServletInputStream() {
|
||||
@Override
|
||||
public int read() {
|
||||
return delegate.read();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFinished() {
|
||||
return delegate.available() == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setReadListener(jakarta.servlet.ReadListener readListener) {
|
||||
// no-op: synchronous reads only in tests
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
-89
@@ -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);
|
||||
}
|
||||
}
|
||||
+1355
File diff suppressed because it is too large
Load Diff
+556
@@ -0,0 +1,556 @@
|
||||
package stirling.software.saas.controller;
|
||||
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.Map;
|
||||
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.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.saas.model.SupabaseUser;
|
||||
import stirling.software.saas.service.SaasUserAccountService;
|
||||
import stirling.software.saas.service.SupabaseUserService;
|
||||
|
||||
/**
|
||||
* Pure-Mockito unit tests for {@link UserRoleWebhookController}.
|
||||
*
|
||||
* <p>The controller is built via {@code @RequiredArgsConstructor}, so {@link InjectMocks} wires the
|
||||
* three mocked collaborators ({@link UserService}, {@link SaasUserAccountService}, {@link
|
||||
* SupabaseUserService}) by type. Each handler is invoked directly and the returned {@link
|
||||
* ResponseEntity} (status + body) is asserted, alongside collaborator interaction verification. No
|
||||
* Spring context, DB, Supabase or network is involved; {@code @PreAuthorize} is a no-op outside the
|
||||
* security proxy so authorization is not exercised here.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class UserRoleWebhookControllerTest {
|
||||
|
||||
@Mock private UserService userService;
|
||||
@Mock private SaasUserAccountService saasUserAccountService;
|
||||
@Mock private SupabaseUserService supabaseUserService;
|
||||
|
||||
@InjectMocks private UserRoleWebhookController controller;
|
||||
|
||||
private static final String SUPABASE_ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
@Nested
|
||||
@DisplayName("POST /upgrade")
|
||||
class HandleUpgrade {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 200 with 'upgraded' message when a promotion happened")
|
||||
void upgraded() {
|
||||
when(saasUserAccountService.handleUpgrade(SUPABASE_ID)).thenReturn(true);
|
||||
|
||||
ResponseEntity<String> response = controller.handleUpgrade(SUPABASE_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isEqualTo("User upgraded to PRO successfully");
|
||||
verify(saasUserAccountService).handleUpgrade(SUPABASE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 200 with 'already PRO' message when nothing changed")
|
||||
void alreadyPro() {
|
||||
when(saasUserAccountService.handleUpgrade(SUPABASE_ID)).thenReturn(false);
|
||||
|
||||
ResponseEntity<String> response = controller.handleUpgrade(SUPABASE_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isEqualTo("User is already PRO");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"maps IllegalArgumentException (bad/unknown supabaseId) to 400 'Invalid request'")
|
||||
void illegalArgumentMapsTo400() {
|
||||
when(saasUserAccountService.handleUpgrade(SUPABASE_ID))
|
||||
.thenThrow(new IllegalArgumentException("Invalid Supabase ID format"));
|
||||
|
||||
ResponseEntity<String> response = controller.handleUpgrade(SUPABASE_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody()).isEqualTo("Invalid request");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("maps any other exception to 500 'Error processing webhook'")
|
||||
void unexpectedExceptionMapsTo500() {
|
||||
when(saasUserAccountService.handleUpgrade(SUPABASE_ID))
|
||||
.thenThrow(new RuntimeException("db down"));
|
||||
|
||||
ResponseEntity<String> response = controller.handleUpgrade(SUPABASE_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
assertThat(response.getBody()).isEqualTo("Error processing webhook");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("POST /downgrade")
|
||||
class HandleDowngrade {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 200 with 'downgraded' message when a demotion happened")
|
||||
void downgraded() {
|
||||
when(saasUserAccountService.handleDowngrade(SUPABASE_ID)).thenReturn(true);
|
||||
|
||||
ResponseEntity<String> response = controller.handleDowngrade(SUPABASE_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isEqualTo("User downgraded to FREE successfully");
|
||||
verify(saasUserAccountService).handleDowngrade(SUPABASE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 200 with 'already FREE' message when nothing changed")
|
||||
void alreadyFree() {
|
||||
when(saasUserAccountService.handleDowngrade(SUPABASE_ID)).thenReturn(false);
|
||||
|
||||
ResponseEntity<String> response = controller.handleDowngrade(SUPABASE_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isEqualTo("User is already on FREE tier");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("maps IllegalArgumentException to 400 'Invalid request'")
|
||||
void illegalArgumentMapsTo400() {
|
||||
when(saasUserAccountService.handleDowngrade(SUPABASE_ID))
|
||||
.thenThrow(new IllegalArgumentException("User not found for Supabase ID"));
|
||||
|
||||
ResponseEntity<String> response = controller.handleDowngrade(SUPABASE_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody()).isEqualTo("Invalid request");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("maps any other exception to 500 'Error processing webhook'")
|
||||
void unexpectedExceptionMapsTo500() {
|
||||
when(saasUserAccountService.handleDowngrade(SUPABASE_ID))
|
||||
.thenThrow(new RuntimeException("boom"));
|
||||
|
||||
ResponseEntity<String> response = controller.handleDowngrade(SUPABASE_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
assertThat(response.getBody()).isEqualTo("Error processing webhook");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("POST /enable-metered-billing")
|
||||
class EnableMeteredBilling {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 200 'enabled' when metered billing is newly turned on")
|
||||
void enabled() {
|
||||
when(saasUserAccountService.enableMeteredBilling(SUPABASE_ID)).thenReturn(true);
|
||||
|
||||
ResponseEntity<String> response = controller.enableMeteredBilling(SUPABASE_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isEqualTo("Metered billing enabled successfully");
|
||||
verify(saasUserAccountService).enableMeteredBilling(SUPABASE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 200 'already enabled' when no change was made")
|
||||
void alreadyEnabled() {
|
||||
when(saasUserAccountService.enableMeteredBilling(SUPABASE_ID)).thenReturn(false);
|
||||
|
||||
ResponseEntity<String> response = controller.enableMeteredBilling(SUPABASE_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isEqualTo("User already has metered billing enabled");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("maps IllegalArgumentException to 400 'Invalid request'")
|
||||
void illegalArgumentMapsTo400() {
|
||||
when(saasUserAccountService.enableMeteredBilling(SUPABASE_ID))
|
||||
.thenThrow(new IllegalArgumentException("Invalid Supabase ID format"));
|
||||
|
||||
ResponseEntity<String> response = controller.enableMeteredBilling(SUPABASE_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody()).isEqualTo("Invalid request");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("maps any other exception to 500 'Error processing webhook'")
|
||||
void unexpectedExceptionMapsTo500() {
|
||||
when(saasUserAccountService.enableMeteredBilling(SUPABASE_ID))
|
||||
.thenThrow(new RuntimeException("stripe down"));
|
||||
|
||||
ResponseEntity<String> response = controller.enableMeteredBilling(SUPABASE_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
assertThat(response.getBody()).isEqualTo("Error processing webhook");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("POST /disable-metered-billing")
|
||||
class DisableMeteredBilling {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 200 'disabled' when metered billing is newly turned off")
|
||||
void disabled() {
|
||||
when(saasUserAccountService.disableMeteredBilling(SUPABASE_ID)).thenReturn(true);
|
||||
|
||||
ResponseEntity<String> response = controller.disableMeteredBilling(SUPABASE_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isEqualTo("Metered billing disabled successfully");
|
||||
verify(saasUserAccountService).disableMeteredBilling(SUPABASE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns 200 'does not have' when no change was made")
|
||||
void notEnabled() {
|
||||
when(saasUserAccountService.disableMeteredBilling(SUPABASE_ID)).thenReturn(false);
|
||||
|
||||
ResponseEntity<String> response = controller.disableMeteredBilling(SUPABASE_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isEqualTo("User does not have metered billing enabled");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("maps IllegalArgumentException to 400 'Invalid request'")
|
||||
void illegalArgumentMapsTo400() {
|
||||
when(saasUserAccountService.disableMeteredBilling(SUPABASE_ID))
|
||||
.thenThrow(new IllegalArgumentException("User not found for Supabase ID"));
|
||||
|
||||
ResponseEntity<String> response = controller.disableMeteredBilling(SUPABASE_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody()).isEqualTo("Invalid request");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("maps any other exception to 500 'Error processing webhook'")
|
||||
void unexpectedExceptionMapsTo500() {
|
||||
when(saasUserAccountService.disableMeteredBilling(SUPABASE_ID))
|
||||
.thenThrow(new RuntimeException("kaboom"));
|
||||
|
||||
ResponseEntity<String> response = controller.disableMeteredBilling(SUPABASE_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
assertThat(response.getBody()).isEqualTo("Error processing webhook");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("POST /promptToAuthUser")
|
||||
class PromptToAuthUser {
|
||||
|
||||
private static final String USERNAME = "anon-user";
|
||||
private static final UUID LINKED_SUPABASE_ID =
|
||||
UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
|
||||
private Principal principal(String name) {
|
||||
Principal p = org.mockito.Mockito.mock(Principal.class);
|
||||
when(p.getName()).thenReturn(name);
|
||||
return p;
|
||||
}
|
||||
|
||||
private User anonymousUser(UUID supabaseId) {
|
||||
User user = new User();
|
||||
user.setUsername(USERNAME);
|
||||
user.setSupabaseId(supabaseId);
|
||||
user.setAuthenticationType(AuthenticationType.ANONYMOUS);
|
||||
return user;
|
||||
}
|
||||
|
||||
private SupabaseUser supabaseUserWithEmail(String email) {
|
||||
SupabaseUser su = new SupabaseUser();
|
||||
su.setId(LINKED_SUPABASE_ID);
|
||||
su.setEmail(email);
|
||||
su.setAnonymous(true);
|
||||
return su;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("happy path: synchronizes upgrade and returns 200 with userId/email body")
|
||||
void happyPath() {
|
||||
User current = anonymousUser(LINKED_SUPABASE_ID);
|
||||
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
|
||||
|
||||
SupabaseUser supabaseUser = supabaseUserWithEmail("new@stirling.com");
|
||||
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
|
||||
|
||||
User upgraded = new User();
|
||||
upgraded.setId(42L);
|
||||
upgraded.setEmail("new@stirling.com");
|
||||
upgraded.setUsername("new@stirling.com");
|
||||
when(saasUserAccountService.synchronizeUserUpgrade(
|
||||
supabaseUser, "new@stirling.com", "google"))
|
||||
.thenReturn(upgraded);
|
||||
|
||||
ResponseEntity<Map<String, String>> response =
|
||||
controller.promptToAuthUser("google", principal(USERNAME));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody())
|
||||
.containsEntry("message", "User upgrade synchronized successfully")
|
||||
.containsEntry("userId", "42")
|
||||
.containsEntry("email", "new@stirling.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("normalizes auth method to lowercase/trimmed before delegating")
|
||||
void normalizesAuthMethod() {
|
||||
User current = anonymousUser(LINKED_SUPABASE_ID);
|
||||
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
|
||||
SupabaseUser supabaseUser = supabaseUserWithEmail("a@b.com");
|
||||
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
|
||||
|
||||
User upgraded = new User();
|
||||
upgraded.setId(7L);
|
||||
upgraded.setEmail("a@b.com");
|
||||
when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), anyString()))
|
||||
.thenReturn(upgraded);
|
||||
|
||||
ResponseEntity<Map<String, String>> response =
|
||||
controller.promptToAuthUser(" GitHub ", principal(USERNAME));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
ArgumentCaptor<String> methodCaptor = ArgumentCaptor.forClass(String.class);
|
||||
verify(saasUserAccountService)
|
||||
.synchronizeUserUpgrade(
|
||||
eq(supabaseUser), eq("a@b.com"), methodCaptor.capture());
|
||||
assertThat(methodCaptor.getValue()).isEqualTo("github");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null authMethod is accepted and passed through as null")
|
||||
void nullAuthMethodAccepted() {
|
||||
User current = anonymousUser(LINKED_SUPABASE_ID);
|
||||
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
|
||||
SupabaseUser supabaseUser = supabaseUserWithEmail("a@b.com");
|
||||
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
|
||||
|
||||
User upgraded = new User();
|
||||
upgraded.setId(7L);
|
||||
upgraded.setEmail("a@b.com");
|
||||
when(saasUserAccountService.synchronizeUserUpgrade(
|
||||
eq(supabaseUser), eq("a@b.com"), eq(null)))
|
||||
.thenReturn(upgraded);
|
||||
|
||||
ResponseEntity<Map<String, String>> response =
|
||||
controller.promptToAuthUser(null, principal(USERNAME));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
verify(saasUserAccountService).synchronizeUserUpgrade(supabaseUser, "a@b.com", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to username in body when upgraded user has no email")
|
||||
void emailFallsBackToUsername() {
|
||||
User current = anonymousUser(LINKED_SUPABASE_ID);
|
||||
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
|
||||
SupabaseUser supabaseUser = supabaseUserWithEmail("canon@b.com");
|
||||
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
|
||||
|
||||
User upgraded = new User();
|
||||
upgraded.setId(9L);
|
||||
upgraded.setEmail(null);
|
||||
upgraded.setUsername("fallback-username");
|
||||
when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), any()))
|
||||
.thenReturn(upgraded);
|
||||
|
||||
ResponseEntity<Map<String, String>> response =
|
||||
controller.promptToAuthUser("email", principal(USERNAME));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).containsEntry("email", "fallback-username");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid auth method returns 400 without touching userService")
|
||||
void invalidAuthMethodRejected() {
|
||||
ResponseEntity<Map<String, String>> response =
|
||||
controller.promptToAuthUser("myspace", principal(USERNAME));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody()).containsEntry("error", "Invalid authentication method");
|
||||
verifyNoInteractions(userService);
|
||||
verifyNoInteractions(saasUserAccountService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown current user (IllegalStateException) maps to 404 'User not found'")
|
||||
void currentUserNotFound() {
|
||||
when(userService.findByUsername(USERNAME)).thenReturn(Optional.empty());
|
||||
|
||||
ResponseEntity<Map<String, String>> response =
|
||||
controller.promptToAuthUser("email", principal(USERNAME));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
assertThat(response.getBody()).containsEntry("error", "User not found");
|
||||
verify(saasUserAccountService, never()).synchronizeUserUpgrade(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("current user without a linked Supabase ID returns 400")
|
||||
void noLinkedSupabaseId() {
|
||||
User current = anonymousUser(null);
|
||||
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
|
||||
|
||||
ResponseEntity<Map<String, String>> response =
|
||||
controller.promptToAuthUser("email", principal(USERNAME));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody())
|
||||
.containsEntry("error", "No Supabase account linked to current user");
|
||||
verifyNoInteractions(supabaseUserService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-anonymous user is rejected with 400")
|
||||
void nonAnonymousRejected() {
|
||||
User current = anonymousUser(LINKED_SUPABASE_ID);
|
||||
current.setAuthenticationType(AuthenticationType.WEB);
|
||||
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
|
||||
when(supabaseUserService.getUser(LINKED_SUPABASE_ID))
|
||||
.thenReturn(supabaseUserWithEmail("x@y.com"));
|
||||
|
||||
ResponseEntity<Map<String, String>> response =
|
||||
controller.promptToAuthUser("email", principal(USERNAME));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody())
|
||||
.containsEntry("error", "Only anonymous users can be upgraded");
|
||||
verify(saasUserAccountService, never()).synchronizeUserUpgrade(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to local user email when Supabase email is blank")
|
||||
void canonicalEmailFallsBackToLocal() {
|
||||
User current = anonymousUser(LINKED_SUPABASE_ID);
|
||||
current.setEmail("local@stirling.com");
|
||||
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
|
||||
|
||||
SupabaseUser supabaseUser = supabaseUserWithEmail(" "); // blank
|
||||
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
|
||||
|
||||
User upgraded = new User();
|
||||
upgraded.setId(5L);
|
||||
upgraded.setEmail("local@stirling.com");
|
||||
when(saasUserAccountService.synchronizeUserUpgrade(
|
||||
eq(supabaseUser), eq("local@stirling.com"), anyString()))
|
||||
.thenReturn(upgraded);
|
||||
|
||||
ResponseEntity<Map<String, String>> response =
|
||||
controller.promptToAuthUser("email", principal(USERNAME));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
verify(saasUserAccountService)
|
||||
.synchronizeUserUpgrade(supabaseUser, "local@stirling.com", "email");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no email anywhere (Supabase and local both blank) returns 400")
|
||||
void noEmailAnywhere() {
|
||||
User current = anonymousUser(LINKED_SUPABASE_ID);
|
||||
current.setEmail(null);
|
||||
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
|
||||
|
||||
SupabaseUser supabaseUser = supabaseUserWithEmail(null);
|
||||
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
|
||||
|
||||
ResponseEntity<Map<String, String>> response =
|
||||
controller.promptToAuthUser("email", principal(USERNAME));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody())
|
||||
.containsEntry("error", "No email associated with user account");
|
||||
verify(saasUserAccountService, never()).synchronizeUserUpgrade(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unexpected RuntimeException from sync maps to 500")
|
||||
void unexpectedExceptionMapsTo500() {
|
||||
User current = anonymousUser(LINKED_SUPABASE_ID);
|
||||
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
|
||||
SupabaseUser supabaseUser = supabaseUserWithEmail("a@b.com");
|
||||
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
|
||||
when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), any()))
|
||||
.thenThrow(new RuntimeException("db down"));
|
||||
|
||||
ResponseEntity<Map<String, String>> response =
|
||||
controller.promptToAuthUser("email", principal(USERNAME));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
assertThat(response.getBody())
|
||||
.containsEntry("error", "Failed to synchronize user upgrade");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getUser throwing (Supabase row missing) maps to 500")
|
||||
void supabaseUserMissingMapsTo500() {
|
||||
User current = anonymousUser(LINKED_SUPABASE_ID);
|
||||
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
|
||||
when(supabaseUserService.getUser(LINKED_SUPABASE_ID))
|
||||
.thenThrow(new RuntimeException("Supabase user not found"));
|
||||
|
||||
ResponseEntity<Map<String, String>> response =
|
||||
controller.promptToAuthUser("email", principal(USERNAME));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
assertThat(response.getBody())
|
||||
.containsEntry("error", "Failed to synchronize user upgrade");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("all allowed auth methods are accepted (none rejected as invalid)")
|
||||
void allowedAuthMethodsAccepted() {
|
||||
for (String method :
|
||||
new String[] {
|
||||
"email", "oauth", "google", "github", "apple", "azure", "linkedin_oidc"
|
||||
}) {
|
||||
User current = anonymousUser(LINKED_SUPABASE_ID);
|
||||
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
|
||||
SupabaseUser supabaseUser = supabaseUserWithEmail("a@b.com");
|
||||
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
|
||||
User upgraded = new User();
|
||||
upgraded.setId(1L);
|
||||
upgraded.setEmail("a@b.com");
|
||||
when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), anyString()))
|
||||
.thenReturn(upgraded);
|
||||
|
||||
ResponseEntity<Map<String, String>> response =
|
||||
controller.promptToAuthUser(method, principal(USERNAME));
|
||||
|
||||
assertThat(response.getStatusCode())
|
||||
.as("method %s should be accepted", method)
|
||||
.isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user