Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce17f10d08 | ||
|
|
a1950185cd | ||
|
|
65493c1919 |
@@ -1,20 +1,20 @@
|
||||
###############################################################################
|
||||
# Stirling-PDF SaaS environment defaults.
|
||||
# Stirling-PDF SaaS local environment template.
|
||||
#
|
||||
# This file is committed and provides non-secret defaults loaded by
|
||||
# `task backend:dev:saas`. Put real values for secrets (passwords, project
|
||||
# refs, edge function secrets) in `.env.saas.local` - any variable set there
|
||||
# takes precedence over what's defined here.
|
||||
# Copy this file to `.env.saas.local` (gitignored) and fill in real values.
|
||||
# Loaded by `task backend:dev:saas` via Taskfile's `dotenv:` directive, then
|
||||
# read by Spring Boot's `${...}` placeholders in application-saas.properties
|
||||
# and application-dev.properties.
|
||||
#
|
||||
# DO NOT commit `.env.saas.local`. Only `.env.saas` is checked in.
|
||||
# DO NOT commit `.env.saas.local`. Only `.env.saas.example` is checked in.
|
||||
###############################################################################
|
||||
|
||||
# ---------- Supabase project ----------
|
||||
# Project reference (the subdomain part of <ref>.supabase.co). Required.
|
||||
# Set in .env.saas.local.
|
||||
# Example dev project:
|
||||
SAAS_DB_PROJECT_REF=
|
||||
|
||||
# Edge function secret used by billing/license rollup calls. Set in .env.saas.local.
|
||||
# Edge function secret used by billing/license rollup calls.
|
||||
SUPABASE_EDGE_FUNCTION_SECRET=
|
||||
|
||||
# ---------- Database (saas profile) ----------
|
||||
@@ -28,7 +28,7 @@ SAAS_DB_PASSWORD=
|
||||
# ---------- Database (dev profile overrides) ----------
|
||||
# Used when `--spring.profiles.include=dev` is active. The dev profile
|
||||
# defaults the URL/username to the shared dev Supabase project, but the
|
||||
# password must still be provided in .env.saas.local.
|
||||
# password must still be provided here.
|
||||
SAAS_DEV_DB_URL=
|
||||
SAAS_DEV_DB_USERNAME=postgres
|
||||
SAAS_DEV_DB_PASSWORD=
|
||||
@@ -38,8 +38,6 @@ project: &project
|
||||
- frontend/**
|
||||
- docker/**
|
||||
- scripts/RestartHelper.java
|
||||
- scripts/db-migration/**
|
||||
- .github/workflows/db-migration-test.yml
|
||||
|
||||
frontend: &frontend
|
||||
- frontend/**
|
||||
|
||||
@@ -13,7 +13,7 @@ Usage:
|
||||
"""
|
||||
|
||||
# Sample for Windows:
|
||||
# python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-GB/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml
|
||||
# python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-GB/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
@@ -308,7 +308,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
report.append("## ❌ Overall Check Status: **_Failed_**")
|
||||
report.append("")
|
||||
report.append(
|
||||
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/editor/public/locales/en-GB/translation.toml)"
|
||||
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-GB/translation.toml)"
|
||||
)
|
||||
else:
|
||||
report.append("## ✅ Overall Check Status: **_Success_**")
|
||||
|
||||
@@ -287,7 +287,6 @@ jobs:
|
||||
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/data:/usr/share/tessdata:rw
|
||||
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw
|
||||
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw
|
||||
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
@@ -310,7 +309,7 @@ jobs:
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
|
||||
# Create V2 PR-specific directories
|
||||
mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs,storage}
|
||||
mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs}
|
||||
|
||||
# Move docker-compose file to correct location
|
||||
mv /tmp/docker-compose-v2.yml /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/docker-compose.yml
|
||||
|
||||
@@ -68,18 +68,6 @@ jobs:
|
||||
uses: ./.github/workflows/backend-build.yml
|
||||
secrets: inherit
|
||||
|
||||
db-migration-test:
|
||||
# Boots the current bootJar against H2 fixtures captured from past
|
||||
# releases (v2.0.0 / v2.5.0 / v2.10.0) and verifies admin login still
|
||||
# works after Hibernate's ddl-auto=update migrates the schema. Gated on
|
||||
# the `project` filter so doc-only PRs skip this ~5-minute job.
|
||||
if: needs.files-changed.outputs.project == 'true'
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/db-migration-test.yml
|
||||
secrets: inherit
|
||||
|
||||
check-generateOpenApiDocs:
|
||||
if: needs.files-changed.outputs.openapi == 'true'
|
||||
needs: [files-changed]
|
||||
@@ -196,7 +184,6 @@ jobs:
|
||||
needs:
|
||||
- files-changed
|
||||
- build
|
||||
- db-migration-test
|
||||
- check-generateOpenApiDocs
|
||||
- frontend-validation
|
||||
- playwright-e2e
|
||||
@@ -221,7 +208,6 @@ jobs:
|
||||
RESULTS: |
|
||||
files-changed=${{ needs.files-changed.result }}
|
||||
build=${{ needs.build.result }}
|
||||
db-migration-test=${{ needs.db-migration-test.result }}
|
||||
check-generateOpenApiDocs=${{ needs.check-generateOpenApiDocs.result }}
|
||||
frontend-validation=${{ needs.frontend-validation.result }}
|
||||
playwright-e2e=${{ needs.playwright-e2e.result }}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
name: DB migration smoke test
|
||||
|
||||
# Boots the current Stirling-PDF JAR against H2 fixtures captured from past
|
||||
# releases (v2.0.0 / v2.5.0 / v2.10.0) and verifies admin login still works.
|
||||
# Catches schema changes that would break existing user databases under
|
||||
# Hibernate's `ddl-auto=update` upgrade path.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
migration-test:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: 25
|
||||
distribution: temurin
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
~/.gradle/caches/modules-2/files-2.1
|
||||
~/.gradle/caches/modules-2/metadata-2.*
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
# No `-PnoSpotless` here yet because the upstream cache layer matches the
|
||||
# backend build's; reuse keeps cold-cache cost identical.
|
||||
- name: Build Stirling-PDF JAR
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
run: ./gradlew :stirling-pdf:bootJar -PnoSpotless --no-daemon
|
||||
|
||||
- name: Locate built JAR
|
||||
id: jar
|
||||
run: |
|
||||
jar=$(find app/core/build/libs -maxdepth 1 -name 'Stirling-PDF*.jar' -o -name 'stirling-pdf*.jar' 2>/dev/null \
|
||||
| grep -vE '(-plain|-sources)\.jar$' | head -n 1)
|
||||
if [[ -z "$jar" ]]; then
|
||||
echo "::error::No JAR under app/core/build/libs"
|
||||
ls -lah app/core/build/libs || true
|
||||
exit 1
|
||||
fi
|
||||
# Absolute path - the migration script pushd's into a temp workdir
|
||||
# before invoking java, which would dangle a relative path.
|
||||
jar=$(realpath "$jar")
|
||||
echo "path=$jar" >> "$GITHUB_OUTPUT"
|
||||
echo "Built JAR: $jar"
|
||||
|
||||
- name: Run migration smoke test
|
||||
env:
|
||||
STIRLING_JAR: ${{ steps.jar.outputs.path }}
|
||||
run: bash scripts/db-migration/run-migration-test.sh
|
||||
|
||||
- name: Upload app logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: db-migration-app-logs
|
||||
# Path matches the preserved workdir in run-migration-test.sh -
|
||||
# only failing fixtures leave a directory behind.
|
||||
path: /tmp/stirling-migration-failed-*/app.log
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
@@ -586,23 +586,21 @@ jobs:
|
||||
if: always() && steps.digicert-setup.conclusion != 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
# Absolute dist path so the cd below can't break the copy targets.
|
||||
DIST="$GITHUB_WORKSPACE/dist"
|
||||
mkdir -p "$DIST"
|
||||
mkdir -p ./dist
|
||||
cd ./frontend/editor/src-tauri/target
|
||||
|
||||
# Find and rename artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
# Only ship the MSI installer on Windows. The loose exe and WiX toolset exes
|
||||
# are not the user-facing installer - the MSI contains the signed inner exe.
|
||||
find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "$DIST/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.rpm" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.rpm" \;
|
||||
find . -name "*.AppImage" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.rpm" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.rpm" \;
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
fi
|
||||
|
||||
- name: Upload build artifacts
|
||||
|
||||
@@ -157,9 +157,6 @@ jobs:
|
||||
JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }}
|
||||
run: task desktop:prepare
|
||||
|
||||
- name: Run Tauri/Cargo tests
|
||||
run: task desktop:test
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
@@ -420,22 +417,20 @@ jobs:
|
||||
- name: Rename artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
# Absolute dist path so the cd below can't break the copy targets.
|
||||
DIST="$GITHUB_WORKSPACE/dist"
|
||||
mkdir -p "$DIST"
|
||||
mkdir -p ./dist
|
||||
cd ./frontend/editor/src-tauri/target
|
||||
|
||||
# Find and rename artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
# Only ship the MSI installer. The loose exe and WiX toolset exes
|
||||
# are not the user-facing installer - the MSI contains the signed inner exe.
|
||||
find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.rpm" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.rpm" \;
|
||||
find . -name "*.AppImage" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.rpm" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.rpm" \;
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
fi
|
||||
|
||||
# Verify the MSI AND the inner exe extracted from it are signed.
|
||||
|
||||
+1
-7
@@ -23,10 +23,6 @@ customFiles/
|
||||
configs/
|
||||
watchedFolders/
|
||||
clientWebUI/
|
||||
# Scratch dir used by local fixture-regeneration runs (see
|
||||
# app/proprietary/src/test/resources/db-migration-fixtures/README.md).
|
||||
# Holds downloaded JARs and disposable workdirs. Never committed.
|
||||
.alpha-local/
|
||||
!cucumber/
|
||||
!cucumber/exampleFiles/
|
||||
!cucumber/exampleFiles/example_html.zip
|
||||
@@ -178,6 +174,7 @@ venv.bak/
|
||||
|
||||
# Env files (secrets / local overrides). Subproject .gitignore files whitelist any committed defaults.
|
||||
.env*
|
||||
!.env.saas.example
|
||||
|
||||
# VS Code
|
||||
/.vscode/**/*
|
||||
@@ -277,6 +274,3 @@ docs/type3/signatures/
|
||||
# Playwright MCP screenshots / traces
|
||||
.playwright-mcp/
|
||||
*.playwright-mcp.png
|
||||
|
||||
# Local screenshot artifacts from *-screenshots.spec.ts
|
||||
frontend/screenshots/
|
||||
|
||||
@@ -40,10 +40,10 @@ tasks:
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:saas:
|
||||
desc: "Start backend in SaaS flavor against Supabase"
|
||||
desc: "Start backend in SaaS flavor against Supabase (loads .env.saas.local)"
|
||||
# `dotenv:` reads from the root Taskfile's directory (".") because this
|
||||
# subtaskfile is included with `dir: .`.
|
||||
dotenv: ['app/.env.saas.local', 'app/.env.saas']
|
||||
# subtaskfile is included with `dir: .`. Drop the file at the repo root.
|
||||
dotenv: ['.env.saas.local']
|
||||
ignore_error: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
|
||||
@@ -78,13 +78,6 @@ tasks:
|
||||
cmds:
|
||||
- npx tauri build --bundles appimage
|
||||
|
||||
test:
|
||||
desc: "Run Tauri/Cargo tests"
|
||||
deps: [prepare]
|
||||
dir: editor/src-tauri
|
||||
cmds:
|
||||
- cargo test
|
||||
|
||||
clean:
|
||||
desc: "Clean Tauri/Cargo build artifacts"
|
||||
dir: editor
|
||||
|
||||
@@ -58,23 +58,6 @@ tasks:
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
|
||||
dev:saas:
|
||||
desc: "Start SaaS backend + frontend concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- task: backend:dev:saas
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
- task: frontend:dev:saas
|
||||
vars:
|
||||
PORT: '{{.FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
|
||||
dev:all:
|
||||
desc: "Start backend + frontend + engine concurrently on free ports"
|
||||
vars:
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
|
||||
# stays ignored via the root .gitignore.
|
||||
!.env.saas
|
||||
@@ -44,10 +44,6 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "The MIT License"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "MIT-0"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.github.jai-imageio:jai-imageio-core",
|
||||
"moduleLicense": "LICENSE.txt"
|
||||
|
||||
@@ -2,7 +2,15 @@ package stirling.software.common.cluster;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/** Token-bucket rate limiting backed by the cluster backplane. */
|
||||
/**
|
||||
* Token-bucket rate limiting backed by the cluster backplane.
|
||||
*
|
||||
* <p>In-process implementations enforce a per-JVM limit (identical to today's behaviour).
|
||||
* Distributed implementations enforce a single global limit across every node.
|
||||
*
|
||||
* <p>Both implementations use a Bucket4j greedy-refill token bucket so semantics match across
|
||||
* single-node and cluster deployments (no fixed-window boundary doubling).
|
||||
*/
|
||||
public interface RateLimitStore {
|
||||
|
||||
/**
|
||||
|
||||
@@ -528,16 +528,6 @@ public class ApplicationProperties {
|
||||
private String provider;
|
||||
private Client client = new Client();
|
||||
|
||||
/**
|
||||
* When true, the OAuth2/OIDC login flow logs the full set of ID token and UserInfo
|
||||
* claims at INFO level (and again at ERROR level if the username attribute cannot be
|
||||
* resolved). Used to diagnose provider misconfiguration (for example ADFS not returning
|
||||
* an {@code email} claim). WARNING: writes PII (sub, email, name) to application logs.
|
||||
* Leave disabled in production; enable only while actively troubleshooting and disable
|
||||
* again afterwards.
|
||||
*/
|
||||
private Boolean debugLogging = false;
|
||||
|
||||
public void setScopes(String scopes) {
|
||||
List<String> scopesList =
|
||||
Arrays.stream(scopes.split(",")).map(String::trim).toList();
|
||||
@@ -788,7 +778,6 @@ public class ApplicationProperties {
|
||||
private boolean enabled = false;
|
||||
private String provider = "local";
|
||||
private Local local = new Local();
|
||||
private S3 s3 = new S3();
|
||||
private Quotas quotas = new Quotas();
|
||||
private Sharing sharing = new Sharing();
|
||||
private Signing signing = new Signing();
|
||||
@@ -798,57 +787,6 @@ public class ApplicationProperties {
|
||||
private String basePath = InstallationPathConfig.getPath() + "storage";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class S3 {
|
||||
/**
|
||||
* Optional custom endpoint (e.g. {@code https://<account>.r2.cloudflarestorage.com},
|
||||
* {@code https://<project>.supabase.co/storage/v1/s3}, or {@code http://localhost:9000}
|
||||
* for MinIO). Blank = use AWS regional default.
|
||||
*/
|
||||
private String endpoint = "";
|
||||
|
||||
private String bucket = "";
|
||||
|
||||
private String region = "us-east-1";
|
||||
|
||||
private String accessKey = "";
|
||||
private String secretKey = "";
|
||||
|
||||
/**
|
||||
* When {@code true} use path-style URLs ({@code <endpoint>/<bucket>/<key>}) instead of
|
||||
* virtual-hosted ({@code <bucket>.<endpoint>/<key>}). MinIO and most S3-compatible
|
||||
* gateways require path-style; AWS S3 prefers virtual-hosted.
|
||||
*/
|
||||
private boolean pathStyleAccess = false;
|
||||
|
||||
/**
|
||||
* When {@code false} (default), {@code endpoint} hostnames that resolve to private,
|
||||
* loopback, or link-local addresses are rejected at startup to block SSRF attacks via
|
||||
* the cloud metadata service (e.g. {@code http://169.254.169.254/}). Set to {@code
|
||||
* true} to opt in for MinIO / in-cluster S3 endpoints on private networks.
|
||||
*/
|
||||
private boolean allowPrivateEndpoints = false;
|
||||
|
||||
/**
|
||||
* Controls when the SDK adds an {@code x-amz-checksum-*} header on PUT/UploadPart.
|
||||
* Default {@code WHEN_SUPPORTED} (the SDK default since 2.30) makes the SDK send a
|
||||
* CRC32 checksum on every upload - this works on AWS S3, MinIO, current Supabase,
|
||||
* Backblaze B2 (post-July-2025), and modern R2. Set to {@code WHEN_REQUIRED} to
|
||||
* suppress the auto-checksum on vendors that reject unknown {@code x-amz-checksum-*}
|
||||
* headers (older Backblaze B2, some R2 corner cases, GCS S3 endpoint). Invalid values
|
||||
* fall back to {@code WHEN_SUPPORTED}.
|
||||
*/
|
||||
private String requestChecksumCalculation = "WHEN_SUPPORTED";
|
||||
|
||||
/**
|
||||
* Controls when the SDK validates returned {@code x-amz-checksum-*} headers on GET
|
||||
* responses. Default {@code WHEN_SUPPORTED}. Set to {@code WHEN_REQUIRED} if your
|
||||
* vendor never returns these headers and you see false-positive checksum-mismatch
|
||||
* errors. Invalid values fall back to {@code WHEN_SUPPORTED}.
|
||||
*/
|
||||
private String responseChecksumValidation = "WHEN_SUPPORTED";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Sharing {
|
||||
private boolean enabled = false;
|
||||
|
||||
+1
-2
@@ -60,7 +60,7 @@ public class CustomPDFDocumentFactory {
|
||||
this(pdfMetadataService, null);
|
||||
}
|
||||
|
||||
/** Documents ≤ this size are loaded entirely into heap — no temp files needed. */
|
||||
/** Documents ≤ this size are loaded entirely into heap - no temp files needed. */
|
||||
public static final long SMALL_FILE_THRESHOLD = 10L * 1024 * 1024; // 10 MB
|
||||
|
||||
/** Upper boundary of the "mixed" memory+file zone; above this always file-backed. */
|
||||
@@ -130,7 +130,6 @@ public class CustomPDFDocumentFactory {
|
||||
MemorySnapshot mem = MemorySnapshot.capture();
|
||||
// Use the overridable method so that test spies (SpyPDFDocumentFactory) can intercept.
|
||||
StreamCacheCreateFunction cache = getStreamCacheFunction(size, mem);
|
||||
// Non-destructive — caller's file is never deleted
|
||||
RandomAccessReadBufferedFile raf = new RandomAccessReadBufferedFile(file);
|
||||
PDDocument doc;
|
||||
try {
|
||||
|
||||
@@ -112,25 +112,12 @@ public class JobExecutorService {
|
||||
|
||||
log.debug("Generated jobId: {} (base: {})", scopedJobKey, baseJobId);
|
||||
|
||||
// Store the scoped job ID in the request for potential use by other components
|
||||
// Store the scoped job ID in the request for potential use by other components.
|
||||
// Ownership lives in the scoped key itself (userId:jobId) plus the cluster-visible
|
||||
// JobStore entry, so we no longer mirror it into the HTTP session - that did not
|
||||
// survive a node hop in cluster mode.
|
||||
if (request != null) {
|
||||
request.setAttribute("jobId", scopedJobKey);
|
||||
|
||||
// Also track this job ID in the user's session for authorization purposes
|
||||
// This ensures users can only cancel their own jobs
|
||||
if (request.getSession() != null) {
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.Set<String> userJobIds =
|
||||
(java.util.Set<String>) request.getSession().getAttribute("userJobIds");
|
||||
|
||||
if (userJobIds == null) {
|
||||
userJobIds = new java.util.concurrent.ConcurrentSkipListSet<>();
|
||||
request.getSession().setAttribute("userJobIds", userJobIds);
|
||||
}
|
||||
|
||||
userJobIds.add(scopedJobKey);
|
||||
log.debug("Added scoped job ID {} to user session", scopedJobKey);
|
||||
}
|
||||
}
|
||||
|
||||
String jobId = scopedJobKey;
|
||||
|
||||
@@ -83,16 +83,7 @@ public class RequestUriUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Blocklist of backend/non-frontend paths that should still go through filters.
|
||||
//
|
||||
// `/files` was historically a backend route; it is now a frontend route
|
||||
// owned by HomePage / FileManagerView. Direct-nav or refresh on /files
|
||||
// (or /files/<folder-uuid>) was returning the Spring auth filter's 401
|
||||
// JSON instead of serving index.html, so the SPA never got a chance to
|
||||
// mount and the user saw a raw error response. There are no `/files`
|
||||
// backend mappings at the servlet root - the real storage endpoints
|
||||
// live under `/api/v1/storage/files`, which is filtered out a few lines
|
||||
// up by the `startsWith("/api/")` guard.
|
||||
// Blocklist of backend/non-frontend paths that should still go through filters
|
||||
String[] backendOnlyPrefixes = {
|
||||
"/register",
|
||||
"/pipeline",
|
||||
@@ -100,6 +91,7 @@ public class RequestUriUtils {
|
||||
"/pdfjs-legacy",
|
||||
"/fonts",
|
||||
"/images",
|
||||
"/files",
|
||||
"/css",
|
||||
"/js",
|
||||
"/swagger",
|
||||
@@ -189,7 +181,7 @@ public class RequestUriUtils {
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|
||||
|| trimmedUri.startsWith("/v1/api-docs")
|
||||
// Workflow participant endpoints - access controlled by share tokens, not login
|
||||
// Workflow participant endpoints — access controlled by share tokens, not login
|
||||
|| trimmedUri.startsWith("/api/v1/workflow/participant/")
|
||||
// Share-link SPA bootstrap; data APIs remain protected
|
||||
|| trimmedUri.matches("^/share/[^/]+/?$");
|
||||
|
||||
+3
-1
@@ -24,7 +24,9 @@ class InProcessDistributedLockTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void reentryFromSameThreadFails() {
|
||||
void reentryFromSameThreadFails_parityWithValkey() {
|
||||
// The Valkey impl refuses reentry (SET NX semantics); the in-process impl must match,
|
||||
// otherwise code working in single-instance silently breaks in cluster mode.
|
||||
DistributedLock lock = new InProcessDistributedLock();
|
||||
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow();
|
||||
Optional<DistributedLock.LockHandle> reentry = lock.tryAcquire("k", Duration.ofSeconds(30));
|
||||
|
||||
+2
@@ -86,6 +86,8 @@ class TaskManagerJobStoreDelegationTest {
|
||||
|
||||
@Override
|
||||
public boolean shouldRunLocalCleanup() {
|
||||
// Distributed backplanes own job TTL eviction themselves; this mock
|
||||
// mirrors the real ValkeyClusterBackplane override of the default true.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,17 +98,6 @@ class RequestUriUtilsTest {
|
||||
assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFrontendRoute_filesRouteOwnedByFrontend() {
|
||||
// /files and /files/<folder-uuid> are FileManagerView routes - they
|
||||
// must fall through to the SPA index.html, not get blocked by the
|
||||
// backend auth filter. Regression test for direct-nav/refresh on
|
||||
// the file manager returning a 401 JSON.
|
||||
assertTrue(RequestUriUtils.isFrontendRoute("", "/files"));
|
||||
assertTrue(
|
||||
RequestUriUtils.isFrontendRoute("", "/files/3331910a-4155-4f71-8111-e38c896bc458"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFrontendRoute_pathWithExtension() {
|
||||
assertFalse(RequestUriUtils.isFrontendRoute("", "/some/file.pdf"));
|
||||
@@ -194,7 +183,7 @@ class RequestUriUtilsTest {
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_shareRootNotPublic() {
|
||||
// Avoid matching bare "/share" or "/share/" - must have a token segment
|
||||
// Avoid matching bare "/share" or "/share/" — must have a token segment
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/share", ""));
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/share/", ""));
|
||||
}
|
||||
@@ -208,7 +197,7 @@ class RequestUriUtilsTest {
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_shareApiStillProtected() {
|
||||
// Share-link data APIs must NOT be public - they enforce auth + access checks
|
||||
// Share-link data APIs must NOT be public — they enforce auth + access checks
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/storage/share-links/abc123", ""));
|
||||
assertFalse(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
|
||||
+42
-11
@@ -40,8 +40,7 @@ public class ScalePagesController {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
private static PDRectangle getTargetSize(
|
||||
String targetPDRectangle, String orientation, PDDocument sourceDocument) {
|
||||
private static PDRectangle getTargetSize(String targetPDRectangle, PDDocument sourceDocument) {
|
||||
if ("KEEP".equals(targetPDRectangle)) {
|
||||
if (sourceDocument.getNumberOfPages() == 0) {
|
||||
throw ExceptionUtils.createInvalidPageSizeException("KEEP");
|
||||
@@ -58,19 +57,18 @@ public class ScalePagesController {
|
||||
}
|
||||
|
||||
Map<String, PDRectangle> sizeMap = getSizeMap();
|
||||
PDRectangle base = sizeMap.get(targetPDRectangle);
|
||||
if (base == null) {
|
||||
throw ExceptionUtils.createInvalidPageSizeException(targetPDRectangle);
|
||||
|
||||
if (sizeMap.containsKey(targetPDRectangle)) {
|
||||
return sizeMap.get(targetPDRectangle);
|
||||
}
|
||||
|
||||
if ("LANDSCAPE".equalsIgnoreCase(orientation)) {
|
||||
return new PDRectangle(base.getHeight(), base.getWidth());
|
||||
}
|
||||
return base;
|
||||
throw ExceptionUtils.createInvalidPageSizeException(targetPDRectangle);
|
||||
}
|
||||
|
||||
private static Map<String, PDRectangle> getSizeMap() {
|
||||
Map<String, PDRectangle> sizeMap = new HashMap<>();
|
||||
|
||||
// Portrait sizes (A0-A6)
|
||||
sizeMap.put("A0", PDRectangle.A0);
|
||||
sizeMap.put("A1", PDRectangle.A1);
|
||||
sizeMap.put("A2", PDRectangle.A2);
|
||||
@@ -78,8 +76,42 @@ public class ScalePagesController {
|
||||
sizeMap.put("A4", PDRectangle.A4);
|
||||
sizeMap.put("A5", PDRectangle.A5);
|
||||
sizeMap.put("A6", PDRectangle.A6);
|
||||
|
||||
// Landscape sizes (A0-A6)
|
||||
sizeMap.put(
|
||||
"A0_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A0.getHeight(), PDRectangle.A0.getWidth()));
|
||||
sizeMap.put(
|
||||
"A1_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A1.getHeight(), PDRectangle.A1.getWidth()));
|
||||
sizeMap.put(
|
||||
"A2_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A2.getHeight(), PDRectangle.A2.getWidth()));
|
||||
sizeMap.put(
|
||||
"A3_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A3.getHeight(), PDRectangle.A3.getWidth()));
|
||||
sizeMap.put(
|
||||
"A4_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth()));
|
||||
sizeMap.put(
|
||||
"A5_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A5.getHeight(), PDRectangle.A5.getWidth()));
|
||||
sizeMap.put(
|
||||
"A6_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A6.getHeight(), PDRectangle.A6.getWidth()));
|
||||
|
||||
// Portrait US sizes
|
||||
sizeMap.put("LETTER", PDRectangle.LETTER);
|
||||
sizeMap.put("LEGAL", PDRectangle.LEGAL);
|
||||
|
||||
// Landscape US sizes
|
||||
sizeMap.put(
|
||||
"LETTER_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.LETTER.getHeight(), PDRectangle.LETTER.getWidth()));
|
||||
sizeMap.put(
|
||||
"LEGAL_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.LEGAL.getHeight(), PDRectangle.LEGAL.getWidth()));
|
||||
|
||||
return sizeMap;
|
||||
}
|
||||
|
||||
@@ -96,14 +128,13 @@ public class ScalePagesController {
|
||||
throws IOException {
|
||||
MultipartFile file = request.getFileInput();
|
||||
String targetPDRectangle = request.getPageSize();
|
||||
String orientation = request.getOrientation();
|
||||
float scaleFactor = request.getScaleFactor();
|
||||
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
PDDocument outputDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
|
||||
|
||||
PDRectangle targetSize = getTargetSize(targetPDRectangle, orientation, sourceDocument);
|
||||
PDRectangle targetSize = getTargetSize(targetPDRectangle, sourceDocument);
|
||||
|
||||
// Create LayerUtility once outside the loop for better performance
|
||||
LayerUtility layerUtility = new LayerUtility(outputDocument);
|
||||
|
||||
+12
-42
@@ -12,8 +12,6 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
@@ -93,44 +91,6 @@ public class ConfigController {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the frontend URL the client should advertise to phones / share-link recipients.
|
||||
* Priority: explicit system.frontendUrl, then the Host the user is already using to reach this
|
||||
* server (works for Docker, reverse proxies, and bare-metal LANs), then a detected site-local
|
||||
* IPv4, then empty.
|
||||
*/
|
||||
// visible for testing
|
||||
String resolveFrontendUrl(HttpServletRequest request, AppConfig appConfig) {
|
||||
String configured = applicationProperties.getSystem().getFrontendUrl();
|
||||
if (configured != null && !configured.isBlank()) {
|
||||
return configured;
|
||||
}
|
||||
if (request != null) {
|
||||
String host = request.getServerName();
|
||||
if (host != null && !host.isBlank() && !isLoopbackHost(host)) {
|
||||
String scheme = request.getScheme();
|
||||
int port = request.getServerPort();
|
||||
boolean defaultPort =
|
||||
("http".equals(scheme) && port == 80)
|
||||
|| ("https".equals(scheme) && port == 443);
|
||||
return defaultPort ? scheme + "://" + host : scheme + "://" + host + ":" + port;
|
||||
}
|
||||
}
|
||||
String localIp = GeneralUtils.getLocalNetworkIp();
|
||||
if (localIp != null) {
|
||||
String scheme = appConfig.getBackendUrl().startsWith("https") ? "https" : "http";
|
||||
return scheme + "://" + localIp + ":" + appConfig.getServerPort();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static boolean isLoopbackHost(String host) {
|
||||
return "localhost".equalsIgnoreCase(host)
|
||||
|| "127.0.0.1".equals(host)
|
||||
|| "::1".equals(host)
|
||||
|| "0:0:0:0:0:0:0:1".equals(host);
|
||||
}
|
||||
|
||||
/** Check if running Enterprise edition dynamically. */
|
||||
private Boolean isRunningEE() {
|
||||
// Use LicenseService for fresh license status if available
|
||||
@@ -147,7 +107,7 @@ public class ConfigController {
|
||||
}
|
||||
|
||||
@GetMapping("/app-config")
|
||||
public ResponseEntity<Map<String, Object>> getAppConfig(HttpServletRequest request) {
|
||||
public ResponseEntity<Map<String, Object>> getAppConfig() {
|
||||
Map<String, Object> configData = new HashMap<>();
|
||||
|
||||
try {
|
||||
@@ -164,7 +124,17 @@ public class ConfigController {
|
||||
configData.put("serverPort", appConfig.getServerPort());
|
||||
|
||||
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
configData.put("frontendUrl", resolveFrontendUrl(request, appConfig));
|
||||
if ((frontendUrl == null || frontendUrl.isBlank())
|
||||
&& Boolean.parseBoolean(
|
||||
System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
|
||||
String localIp = GeneralUtils.getLocalNetworkIp();
|
||||
if (localIp != null) {
|
||||
String scheme =
|
||||
appConfig.getBackendUrl().startsWith("https") ? "https" : "http";
|
||||
frontendUrl = scheme + "://" + localIp + ":" + appConfig.getServerPort();
|
||||
}
|
||||
}
|
||||
configData.put("frontendUrl", frontendUrl != null ? frontendUrl : "");
|
||||
|
||||
// Add mobile scanner settings
|
||||
configData.put(
|
||||
|
||||
+2
-7
@@ -160,19 +160,14 @@ public class ReactRoutingController {
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedCallbackHtml);
|
||||
}
|
||||
|
||||
// `files` was historically a backend static-asset directory and was therefore
|
||||
// in the exclusion list - removing it lets /files and /files/<folder-uuid>
|
||||
// forward to the SPA index.html, which is what FileManagerView expects.
|
||||
// (Real storage endpoints live under /api/v1/storage/files, already
|
||||
// excluded by the leading `api` token in the same regex.)
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
|
||||
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
|
||||
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
|
||||
throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
|
||||
+2
-40
@@ -22,7 +22,6 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -197,12 +196,12 @@ public class GlobalExceptionHandler {
|
||||
/**
|
||||
* Checks whether the given IOException indicates that the client disconnected before the
|
||||
* response could be written (broken pipe, connection reset, etc.). When this happens there is
|
||||
* no point in serialising a {@link ProblemDetail} body because the socket is already closed -
|
||||
* no point in serialising a {@link ProblemDetail} body because the socket is already closed —
|
||||
* and attempting to do so may trigger a secondary {@code HttpMessageNotWritableException} if
|
||||
* the response Content-Type was already committed as a non-JSON type (e.g. image/png).
|
||||
*/
|
||||
private static boolean isClientDisconnectException(IOException ex) {
|
||||
// Walk the causal chain - Jetty/Tomcat may wrap the low-level SocketException
|
||||
// Walk the causal chain — Jetty/Tomcat may wrap the low-level SocketException
|
||||
Throwable current = ex;
|
||||
while (current != null) {
|
||||
String msg = current.getMessage();
|
||||
@@ -1041,43 +1040,6 @@ public class GlobalExceptionHandler {
|
||||
* @param request the HTTP servlet request
|
||||
* @return ProblemDetail with appropriate HTTP status
|
||||
*/
|
||||
/**
|
||||
* Handle ResponseStatusException explicitly so its embedded HTTP status reaches the client
|
||||
* instead of being swallowed by the {@code RuntimeException} catch-all (which would downgrade
|
||||
* every controller-thrown 400/404/409 to a generic 500). Folder/file storage controllers and
|
||||
* any other code that throws {@code ResponseStatusException} relies on this handler taking
|
||||
* precedence.
|
||||
*/
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<ProblemDetail> handleResponseStatusException(
|
||||
ResponseStatusException ex, HttpServletRequest request) {
|
||||
HttpStatus status =
|
||||
HttpStatus.resolve(ex.getStatusCode().value()) != null
|
||||
? HttpStatus.valueOf(ex.getStatusCode().value())
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
String reason = ex.getReason() != null ? ex.getReason() : status.getReasonPhrase();
|
||||
ProblemDetail problemDetail = createBaseProblemDetail(status, reason, request);
|
||||
problemDetail.setType(URI.create("/errors/" + status.value()));
|
||||
problemDetail.setTitle(status.getReasonPhrase());
|
||||
problemDetail.setProperty("title", status.getReasonPhrase());
|
||||
// 5xx is operator-relevant; 4xx is a normal client-rejection - log at the right level.
|
||||
if (status.is5xxServerError()) {
|
||||
log.error(
|
||||
"ResponseStatusException {} at {}: {}",
|
||||
status.value(),
|
||||
request.getRequestURI(),
|
||||
reason,
|
||||
ex);
|
||||
} else {
|
||||
log.debug(
|
||||
"ResponseStatusException {} at {}: {}",
|
||||
status.value(),
|
||||
request.getRequestURI(),
|
||||
reason);
|
||||
}
|
||||
return ResponseEntity.status(status).contentType(PROBLEM_JSON).body(problemDetail);
|
||||
}
|
||||
|
||||
@ExceptionHandler(RuntimeException.class)
|
||||
public ResponseEntity<ProblemDetail> handleRuntimeException(
|
||||
RuntimeException ex, HttpServletRequest request) {
|
||||
|
||||
@@ -18,11 +18,4 @@ public class PDFWithPageSize extends PDFFile {
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
allowableValues = {"A0", "A1", "A2", "A3", "A4", "A5", "A6", "LETTER", "LEGAL", "KEEP"})
|
||||
private String pageSize;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Orientation to apply to the target page size. Ignored when pageSize is KEEP.",
|
||||
defaultValue = "PORTRAIT",
|
||||
allowableValues = {"PORTRAIT", "LANDSCAPE"})
|
||||
private String orientation = "PORTRAIT";
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -22,6 +23,10 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.ClusterBackplane;
|
||||
import stirling.software.common.cluster.JobStore;
|
||||
import stirling.software.common.cluster.JobStoreEntry;
|
||||
import stirling.software.common.cluster.StickyMissRecorder;
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
@@ -42,10 +47,22 @@ public class JobController {
|
||||
private final FileStorage fileStorage;
|
||||
private final JobQueue jobQueue;
|
||||
private final HttpServletRequest request;
|
||||
private final ClusterBackplane clusterBackplane;
|
||||
private final JobStore jobStore;
|
||||
|
||||
/**
|
||||
* Process-local short-TTL cache fronting {@link JobStore#get(String)} on the sticky-410 path.
|
||||
* Without this every result download / status poll fires a Valkey HGETALL which doubles RTT on
|
||||
* the hot path when the same client re-requests the same job within seconds.
|
||||
*/
|
||||
private final JobOwnershipCache ownershipCache = new JobOwnershipCache();
|
||||
|
||||
@Autowired(required = false)
|
||||
private JobOwnershipService jobOwnershipService;
|
||||
|
||||
@Autowired(required = false)
|
||||
private StickyMissRecorder stickyMissRecorder;
|
||||
|
||||
/**
|
||||
* Get the status of a job
|
||||
*
|
||||
@@ -55,6 +72,14 @@ public class JobController {
|
||||
@GetMapping("/job/{jobId}")
|
||||
@Operation(summary = "Get job status")
|
||||
public ResponseEntity<?> getJobStatus(@PathVariable("jobId") String jobId) {
|
||||
// Sticky-410 must precede user-auth (403): a non-owner node has no way to verify
|
||||
// ownership for a job it doesn't own, and a 403 here would leak job existence to
|
||||
// unauthorized callers. Return 410 first so the LB re-routes to the owner.
|
||||
Optional<ResponseEntity<?>> peerOwned = guardNonOwner(jobId);
|
||||
if (peerOwned.isPresent()) {
|
||||
return peerOwned.get();
|
||||
}
|
||||
|
||||
// Validate job ownership
|
||||
if (!validateJobAccess(jobId)) {
|
||||
log.warn("Unauthorized attempt to access job status: {}", jobId);
|
||||
@@ -91,6 +116,14 @@ public class JobController {
|
||||
@GetMapping("/job/{jobId}/result")
|
||||
@Operation(summary = "Get job result")
|
||||
public ResponseEntity<?> getJobResult(@PathVariable("jobId") String jobId) {
|
||||
// Sticky-410 must precede user-auth (403): a non-owner node has no way to verify
|
||||
// ownership for a job it doesn't own, and a 403 here would leak job existence to
|
||||
// unauthorized callers. Return 410 first so the LB re-routes to the owner.
|
||||
Optional<ResponseEntity<?>> peerOwned = guardNonOwner(jobId);
|
||||
if (peerOwned.isPresent()) {
|
||||
return peerOwned.get();
|
||||
}
|
||||
|
||||
// Validate job ownership
|
||||
if (!validateJobAccess(jobId)) {
|
||||
log.warn("Unauthorized attempt to access job result: {}", jobId);
|
||||
@@ -125,11 +158,14 @@ public class JobController {
|
||||
result.getAllResultFiles()));
|
||||
}
|
||||
|
||||
// Handle single file (download directly)
|
||||
// Handle single file (download directly). Cross-node ownership was already resolved
|
||||
// at the top of this method, so reaching here means we ARE the owner (or single-node)
|
||||
// and the bytes live on our local disk.
|
||||
if (result.hasFiles() && !result.hasMultipleFiles()) {
|
||||
try {
|
||||
List<ResultFile> files = result.getAllResultFiles();
|
||||
ResultFile singleFile = files.get(0);
|
||||
|
||||
byte[] fileContent = fileStorage.retrieveBytes(singleFile.getFileId());
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Type", singleFile.getContentType())
|
||||
@@ -163,6 +199,15 @@ public class JobController {
|
||||
public ResponseEntity<?> cancelJob(@PathVariable("jobId") String jobId) {
|
||||
log.debug("Request to cancel job: {}", jobId);
|
||||
|
||||
// Sticky-410 must precede user-auth (403): a non-owner node has no way to verify
|
||||
// ownership for a job it doesn't own, and a 403 here would leak job existence to
|
||||
// unauthorized callers. Return 410 first so the LB re-routes to the owner who can
|
||||
// actually cancel.
|
||||
Optional<ResponseEntity<?>> peerOwned = guardNonOwner(jobId);
|
||||
if (peerOwned.isPresent()) {
|
||||
return peerOwned.get();
|
||||
}
|
||||
|
||||
// Validate job ownership
|
||||
if (!validateJobAccess(jobId)) {
|
||||
log.warn("Unauthorized attempt to cancel job: {}", jobId);
|
||||
@@ -201,7 +246,9 @@ public class JobController {
|
||||
"queuePosition",
|
||||
queuePosition >= 0 ? queuePosition : "n/a"));
|
||||
} else {
|
||||
// Job not found or already complete
|
||||
// Job not found or already complete. Cross-node ownership was already resolved at
|
||||
// the top of this method (sticky-410 precedes user-auth), so any peer-owned case
|
||||
// has been returned already; reaching here means we ARE the owner (or single-node).
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
if (result == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@@ -224,6 +271,14 @@ public class JobController {
|
||||
@GetMapping("/job/{jobId}/result/files")
|
||||
@Operation(summary = "Get job result files")
|
||||
public ResponseEntity<?> getJobFiles(@PathVariable("jobId") String jobId) {
|
||||
// Sticky-410 must precede user-auth (403): a non-owner node has no way to verify
|
||||
// ownership for a job it doesn't own, and a 403 here would leak job existence to
|
||||
// unauthorized callers. Return 410 first so the LB re-routes to the owner.
|
||||
Optional<ResponseEntity<?>> peerOwned = guardNonOwner(jobId);
|
||||
if (peerOwned.isPresent()) {
|
||||
return peerOwned.get();
|
||||
}
|
||||
|
||||
// Validate job ownership
|
||||
if (!validateJobAccess(jobId)) {
|
||||
log.warn("Unauthorized attempt to access job files: {}", jobId);
|
||||
@@ -267,6 +322,14 @@ public class JobController {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// Sticky-410 must precede user-auth (403): a non-owner node has no way to verify
|
||||
// ownership for a job it doesn't own, and a 403 here would leak file existence to
|
||||
// unauthorized callers. Return 410 first so the LB re-routes to the owner.
|
||||
Optional<ResponseEntity<?>> notOwner = guardNonOwner(jobKey);
|
||||
if (notOwner.isPresent()) {
|
||||
return notOwner.get();
|
||||
}
|
||||
|
||||
if (!validateJobAccess(jobKey)) {
|
||||
log.warn("Unauthorized attempt to access file metadata: {}", fileId);
|
||||
return ResponseEntity.status(403)
|
||||
@@ -323,15 +386,21 @@ public class JobController {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// Sticky-410 must precede the user-auth (403) check: a non-owner node has no way to
|
||||
// verify ownership for a job it doesn't own, and a 403 here would leak file existence
|
||||
// to unauthorized callers. Return 410 first so the LB re-routes to the owner where
|
||||
// the real auth check can run.
|
||||
Optional<ResponseEntity<?>> notOwner = guardNonOwner(jobKey);
|
||||
if (notOwner.isPresent()) {
|
||||
return notOwner.get();
|
||||
}
|
||||
|
||||
if (!validateJobAccess(jobKey)) {
|
||||
log.warn("Unauthorized attempt to download file: {}", fileId);
|
||||
return ResponseEntity.status(403)
|
||||
.body(Map.of("message", "You are not authorized to access this file"));
|
||||
}
|
||||
|
||||
// Retrieve file content
|
||||
byte[] fileContent = fileStorage.retrieveBytes(fileId);
|
||||
|
||||
// Find the file metadata from any job that contains this file
|
||||
// This is for getting the original filename and content type
|
||||
ResultFile resultFile = taskManager.findResultFileByFileId(fileId);
|
||||
@@ -342,6 +411,9 @@ public class JobController {
|
||||
? resultFile.getContentType()
|
||||
: MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
|
||||
// Retrieve file content from local disk
|
||||
byte[] fileContent = fileStorage.retrieveBytes(fileId);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Type", contentType)
|
||||
.header("Content-Disposition", createContentDispositionHeader(fileName))
|
||||
@@ -356,6 +428,76 @@ public class JobController {
|
||||
return jobOwnershipService != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code 410 Gone} with {@code {message, ownedBy, currentNode}} and {@code Retry-After:
|
||||
* 0} when the job is owned by a peer node. Returns {@link Optional#empty()} when we are the
|
||||
* owner, when cluster mode is off / JobStore has no entry, or when {@code owningNodeId} is
|
||||
* blank (caller proceeds with its normal not-found / 200 path).
|
||||
*
|
||||
* <p>Wraps the {@link JobStore#get(String)} call in a short-TTL local cache and a defensive
|
||||
* try/catch so that Valkey RTT cost is not multiplied by every download retry and so that a
|
||||
* Valkey timeout falls through to the local-disk path instead of surfacing as 500.
|
||||
*/
|
||||
private Optional<ResponseEntity<?>> guardNonOwner(String jobId) {
|
||||
if (clusterBackplane == null || jobStore == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Optional<JobStoreEntry> entry;
|
||||
Optional<Optional<JobStoreEntry>> cached = ownershipCache.get(jobId);
|
||||
if (cached.isPresent()) {
|
||||
entry = cached.get();
|
||||
} else {
|
||||
try {
|
||||
entry = jobStore.get(jobId);
|
||||
} catch (RuntimeException ex) {
|
||||
// Valkey unavailable / timeout: treat as "no cluster-visible entry" so the request
|
||||
// can proceed to the local-disk path. Surfacing a 500 here would break every
|
||||
// download attempt during a brief Valkey blip; the worst case if we miss a real
|
||||
// peer-owned entry is one wasted round trip + a 404 from the local node.
|
||||
log.warn(
|
||||
"JobStore lookup failed for jobId={} - treating as not-found and falling"
|
||||
+ " through to local path: {}",
|
||||
jobId,
|
||||
ex.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
ownershipCache.put(jobId, entry);
|
||||
}
|
||||
if (entry.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String owner = entry.get().owningNodeId();
|
||||
if (owner == null || owner.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String localId = clusterBackplane.localNodeId();
|
||||
if (owner.equals(localId)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
log.info(
|
||||
"Sticky-session miss for jobId={} (owner={}, local={}); returning 410 so client"
|
||||
+ " retries via LB affinity",
|
||||
jobId,
|
||||
owner,
|
||||
localId);
|
||||
if (stickyMissRecorder != null) {
|
||||
stickyMissRecorder.recordStickyMiss();
|
||||
}
|
||||
return Optional.of(
|
||||
ResponseEntity.status(410)
|
||||
.header("Retry-After", "0")
|
||||
.body(
|
||||
Map.of(
|
||||
"message",
|
||||
"Result lives on another node. Retry to be routed there"
|
||||
+ " by the load balancer's sticky-session"
|
||||
+ " affinity, or re-run the job.",
|
||||
"ownedBy",
|
||||
owner,
|
||||
"currentNode",
|
||||
localId == null ? "" : localId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Content-Disposition header with UTF-8 filename support
|
||||
*
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package stirling.software.common.controller;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
import stirling.software.common.cluster.JobStoreEntry;
|
||||
|
||||
/**
|
||||
* Process-local TTL cache for {@link JobStoreEntry} lookups to suppress redundant Valkey HGETALL
|
||||
* round-trips on the hot result-download path (sticky-410 ownership check).
|
||||
*
|
||||
* <p>5 second TTL is short enough that a job's lifecycle transitions (RUNNING -> COMPLETE -> TTL
|
||||
* expiry) propagate to all nodes within the LB's sticky-session window, and short enough that a
|
||||
* mistakenly-cached "not found" recovers quickly when an entry actually shows up. Cap the map at
|
||||
* 2048 entries to bound memory; eviction is best-effort (clear-and-restart) since the cache is
|
||||
* advisory.
|
||||
*/
|
||||
final class JobOwnershipCache {
|
||||
|
||||
private static final long TTL_NANOS = 5L * 1_000_000_000L; // 5 s
|
||||
private static final int MAX_ENTRIES = 2048;
|
||||
|
||||
private final ConcurrentMap<String, Entry> entries = new ConcurrentHashMap<>();
|
||||
|
||||
Optional<Optional<JobStoreEntry>> get(String jobId) {
|
||||
Entry e = entries.get(jobId);
|
||||
if (e == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (System.nanoTime() - e.storedAtNanos > TTL_NANOS) {
|
||||
entries.remove(jobId, e);
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(e.value);
|
||||
}
|
||||
|
||||
void put(String jobId, Optional<JobStoreEntry> value) {
|
||||
if (entries.size() >= MAX_ENTRIES) {
|
||||
// Best-effort eviction; under burst the cache simply rebuilds.
|
||||
entries.clear();
|
||||
}
|
||||
entries.put(jobId, new Entry(value, System.nanoTime()));
|
||||
}
|
||||
|
||||
void invalidate(String jobId) {
|
||||
entries.remove(jobId);
|
||||
}
|
||||
|
||||
private record Entry(Optional<JobStoreEntry> value, long storedAtNanos) {}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ logging.level.stirling.software.common.service.TaskManager=INFO
|
||||
spring.jpa.open-in-view=false
|
||||
server.forward-headers-strategy=NATIVE
|
||||
|
||||
# Prevent Spring Boot auto-configuring a session repository from spring-session-data-redis being on
|
||||
# the classpath. ClusterSessionConfiguration enables Redis-backed sessions when cluster.enabled=true.
|
||||
spring.session.store-type=none
|
||||
|
||||
# Enable HTTP/2 for improved performance (multiplexed streams, header compression)
|
||||
server.http2.enabled=true
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ security:
|
||||
password: "" # initial password for the first login
|
||||
oauth2:
|
||||
enabled: false # set to 'true' to enable login (Note: enableLogin must also be 'true' for this to work)
|
||||
debugLogging: false # set to 'true' to log full ID token and UserInfo claims during OAuth2/OIDC login. Use this to diagnose claim issues (e.g. "Attribute value for 'email' cannot be null" with ADFS). WARNING: writes PII (sub, email, name) to logs; disable after troubleshooting.
|
||||
client:
|
||||
keycloak:
|
||||
issuer: "" # URL of the Keycloak realm's OpenID Connect Discovery endpoint
|
||||
@@ -246,39 +245,6 @@ storage:
|
||||
provider: local # storage provider: 'local' for filesystem storage, 'database' for DB-backed storage
|
||||
local:
|
||||
basePath: './storage' # base directory for stored files
|
||||
# ====================================================================================
|
||||
# S3-COMPATIBLE OBJECT STORAGE - PRO / ENTERPRISE LICENSE REQUIRED
|
||||
# storage.provider=s3, storage.provider=database, and cluster.artifactStore=s3 all
|
||||
# require a valid Pro or Enterprise license.
|
||||
# ====================================================================================
|
||||
# Used when provider=s3 (persistent user uploads) and/or cluster.artifactStore=s3
|
||||
# (transient cluster artifacts). The two consumers share this block.
|
||||
# Vendor cheat sheet (set the highlighted flags to taste):
|
||||
# AWS S3 -> endpoint='' region='<your-region>' pathStyleAccess=false
|
||||
# Cloudflare R2 -> endpoint='https://<acct>.r2.cloudflarestorage.com' region='auto'
|
||||
# pathStyleAccess=false; if uploads fail with 'unsupported header
|
||||
# x-amz-checksum-*' set requestChecksumCalculation=WHEN_REQUIRED
|
||||
# Supabase Storage -> endpoint='https://<project>.supabase.co/storage/v1/s3'
|
||||
# region='<project-region>' pathStyleAccess=true
|
||||
# (filenames with non-ASCII display fine - the storage key is opaque)
|
||||
# MinIO (in-cluster) -> endpoint='http://minio:9000' region='us-east-1'
|
||||
# pathStyleAccess=true allowPrivateEndpoints=true
|
||||
# Backblaze B2 -> endpoint='https://s3.<region>.backblazeb2.com'
|
||||
# If on a B2 deployment older than July-2025 and uploads return
|
||||
# 'Unsupported header x-amz-checksum-crc32', set
|
||||
# requestChecksumCalculation=WHEN_REQUIRED
|
||||
# DigitalOcean Spaces -> endpoint='https://<region>.digitaloceanspaces.com'
|
||||
# Note: 5GB per-object cap (regardless of multipart)
|
||||
s3:
|
||||
endpoint: "" # blank = use AWS regional default; otherwise full URL incl. https://
|
||||
bucket: "" # required when provider=s3 or cluster.artifactStore=s3
|
||||
region: us-east-1
|
||||
accessKey: "" # blank = fall back to AWS DefaultCredentialsProvider (env / profile / IMDS)
|
||||
secretKey: ""
|
||||
pathStyleAccess: false # true for MinIO and Supabase; false for AWS/R2/most CDNs
|
||||
allowPrivateEndpoints: false # true required when endpoint resolves to a private/loopback IP (e.g. in-cluster MinIO). SSRF guard - leave false for any internet-facing vendor.
|
||||
requestChecksumCalculation: WHEN_SUPPORTED # WHEN_SUPPORTED|WHEN_REQUIRED|DISABLED. Set WHEN_REQUIRED if your vendor rejects auto-added x-amz-checksum-* headers (older Backblaze B2, some R2 corner cases).
|
||||
responseChecksumValidation: WHEN_SUPPORTED # WHEN_SUPPORTED|WHEN_REQUIRED|DISABLED. Set WHEN_REQUIRED if you see false-positive checksum-mismatch errors on GET from a vendor that never returns checksum headers.
|
||||
quotas:
|
||||
maxStorageMbPerUser: -1 # Max storage per user in MB; -1 disables per-user cap
|
||||
maxStorageMbTotal: -1 # Max storage across all users in MB; -1 disables total cap
|
||||
@@ -369,8 +335,6 @@ cluster:
|
||||
enabled: false # Master switch. 'false' (default) wires the in-process backplane and skips all cluster checks. Single-instance installs do not need to change anything here.
|
||||
backplane: inprocess # Backplane implementation: 'inprocess' (single JVM only) or 'valkey' (multi-node via Valkey/Redis)
|
||||
artifactStore: local # Transient cluster job-artifact backend: 'local' (per-node disk; single-node only) or 's3' (shared object store; required for multi-node). Distinct from 'storage.provider' which controls persistent user uploads - when both are 's3' they share the storage.s3.* credentials block. Multi-node deployments MUST set this to 's3'.
|
||||
s3:
|
||||
keyPrefix: transient/ # Bucket key prefix used by the cluster artifact store when artifactStore=s3. Trailing slash recommended. Lets a single bucket host both persistent uploads (storage.s3.*) and transient job artifacts under separate prefixes.
|
||||
valkey:
|
||||
url: "" # Valkey/Redis URL, e.g. 'redis://valkey:6379' or 'rediss://...' for TLS. Required when enabled=true and backplane=valkey.
|
||||
tls:
|
||||
|
||||
+1
-2
@@ -237,8 +237,7 @@ class ScalePagesControllerTest {
|
||||
|
||||
ScalePagesRequest request = new ScalePagesRequest();
|
||||
request.setFileInput(file);
|
||||
request.setPageSize("A4");
|
||||
request.setOrientation("LANDSCAPE");
|
||||
request.setPageSize("A4_LANDSCAPE");
|
||||
request.setScaleFactor(1.0f);
|
||||
|
||||
setupFactory();
|
||||
|
||||
-71
@@ -15,14 +15,10 @@ import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.System;
|
||||
import stirling.software.common.service.LicenseServiceInterface;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
@@ -177,71 +173,4 @@ class ConfigControllerTest {
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
verify(endpointConfiguration).getAllEndpoints();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveFrontendUrl_prefersExplicitConfiguredValue() {
|
||||
System sys = mock(System.class);
|
||||
when(applicationProperties.getSystem()).thenReturn(sys);
|
||||
when(sys.getFrontendUrl()).thenReturn("https://pdf.example.com");
|
||||
|
||||
// Request would say something else, but configured wins.
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
AppConfig appConfig = mock(AppConfig.class);
|
||||
|
||||
assertEquals(
|
||||
"https://pdf.example.com", configController.resolveFrontendUrl(req, appConfig));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveFrontendUrl_usesRequestHostWhenNotConfigured() {
|
||||
System sys = mock(System.class);
|
||||
when(applicationProperties.getSystem()).thenReturn(sys);
|
||||
when(sys.getFrontendUrl()).thenReturn(null);
|
||||
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
when(req.getServerName()).thenReturn("192.168.1.100");
|
||||
when(req.getScheme()).thenReturn("http");
|
||||
when(req.getServerPort()).thenReturn(8080);
|
||||
|
||||
assertEquals(
|
||||
"http://192.168.1.100:8080",
|
||||
configController.resolveFrontendUrl(req, mock(AppConfig.class)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveFrontendUrl_elidesDefaultHttpsPort() {
|
||||
System sys = mock(System.class);
|
||||
when(applicationProperties.getSystem()).thenReturn(sys);
|
||||
when(sys.getFrontendUrl()).thenReturn("");
|
||||
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
when(req.getServerName()).thenReturn("pdf.example.com");
|
||||
when(req.getScheme()).thenReturn("https");
|
||||
when(req.getServerPort()).thenReturn(443);
|
||||
|
||||
assertEquals(
|
||||
"https://pdf.example.com",
|
||||
configController.resolveFrontendUrl(req, mock(AppConfig.class)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveFrontendUrl_fallsThroughOnLoopbackHost() {
|
||||
System sys = mock(System.class);
|
||||
when(applicationProperties.getSystem()).thenReturn(sys);
|
||||
when(sys.getFrontendUrl()).thenReturn(null);
|
||||
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
when(req.getServerName()).thenReturn("localhost");
|
||||
|
||||
AppConfig appConfig = mock(AppConfig.class);
|
||||
when(appConfig.getBackendUrl()).thenReturn("http://localhost:8080");
|
||||
when(appConfig.getServerPort()).thenReturn("8080");
|
||||
|
||||
// Detected IP (if any) wins over loopback request host. We can't assert the
|
||||
// exact value (depends on the host running the test) but we can assert it
|
||||
// never returns "localhost".
|
||||
String result = configController.resolveFrontendUrl(req, appConfig);
|
||||
assertNotNull(result);
|
||||
assertFalse(result.contains("localhost"));
|
||||
}
|
||||
}
|
||||
|
||||
+502
@@ -0,0 +1,502 @@
|
||||
package stirling.software.common.controller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.common.cluster.ClusterBackplane;
|
||||
import stirling.software.common.cluster.JobStore;
|
||||
import stirling.software.common.cluster.JobStoreEntry;
|
||||
import stirling.software.common.cluster.StickyMissRecorder;
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.service.JobQueue;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
|
||||
/**
|
||||
* Sticky-session ownership behavior for {@link JobController}.
|
||||
*
|
||||
* <p>Result PDFs live on the local disk of whichever node ran the job. When the load balancer's
|
||||
* cookie/IP affinity *misses* and routes a download to a non-owner node, the controller must return
|
||||
* {@code 410 Gone} with a structured payload that tells the client to retry (the LB will usually
|
||||
* route them to the owner on the second attempt).
|
||||
*
|
||||
* <p>Contract verified here:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Owner == this node → file is read from local disk (normal 200).
|
||||
* <li>Owner == another node → {@code 410 Gone} with {@code ownedBy} + {@code currentNode} fields,
|
||||
* and {@code FileStorage} is <b>never</b> touched.
|
||||
* <li>JobStore has no entry → behave as single-instance (no 410).
|
||||
* <li>{@code owningNodeId} blank → behave as single-instance (no 410).
|
||||
* <li>Single-instance install (no JobStore / no ClusterBackplane) → no 410, no NPE.
|
||||
* </ul>
|
||||
*
|
||||
* <p>Manual mock construction (no {@code MockitoExtension}) so each test can wire its own
|
||||
* controller with a different {@code ClusterBackplane} / {@code JobStore} combo without setUp stubs
|
||||
* leaking across cases.
|
||||
*/
|
||||
class JobControllerOwnershipTest {
|
||||
|
||||
private TaskManager taskManager;
|
||||
private FileStorage fileStorage;
|
||||
private JobQueue jobQueue;
|
||||
private HttpServletRequest request;
|
||||
private JobOwnershipService jobOwnershipService;
|
||||
private ClusterBackplane clusterBackplane;
|
||||
private JobStore jobStore;
|
||||
private StickyMissRecorder stickyMissRecorder;
|
||||
|
||||
private static final String JOB_ID = "job-42";
|
||||
private static final String FILE_ID = "file-abc";
|
||||
private static final String LOCAL_NODE = "node-self";
|
||||
private static final String PEER_NODE = "node-peer";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
taskManager = mock(TaskManager.class);
|
||||
fileStorage = mock(FileStorage.class);
|
||||
jobQueue = mock(JobQueue.class);
|
||||
request = mock(HttpServletRequest.class);
|
||||
jobOwnershipService = mock(JobOwnershipService.class);
|
||||
clusterBackplane = mock(ClusterBackplane.class);
|
||||
jobStore = mock(JobStore.class);
|
||||
stickyMissRecorder = mock(StickyMissRecorder.class);
|
||||
}
|
||||
|
||||
private JobController makeController(ClusterBackplane backplane, JobStore store) {
|
||||
JobController c =
|
||||
new JobController(taskManager, fileStorage, jobQueue, request, backplane, store);
|
||||
// jobOwnershipService is @Autowired(required=false) - field-injected. When non-null,
|
||||
// validateJobAccess delegates to it. We leave it null by default so the security
|
||||
// check is a no-op (backwards compat path) and the test focuses on sticky-410.
|
||||
// stickyMissRecorder is also field-injected; wire by default so the metric assertions
|
||||
// work without per-test setup.
|
||||
ReflectionTestUtils.setField(c, "stickyMissRecorder", stickyMissRecorder);
|
||||
return c;
|
||||
}
|
||||
|
||||
private JobController makeController() {
|
||||
return makeController(clusterBackplane, jobStore);
|
||||
}
|
||||
|
||||
private JobStoreEntry entryOwnedBy(String ownerNodeId) {
|
||||
return new JobStoreEntry(
|
||||
JOB_ID,
|
||||
JobStoreEntry.JobState.COMPLETE,
|
||||
ownerNodeId,
|
||||
Instant.now(),
|
||||
Instant.now(),
|
||||
null,
|
||||
List.of(FILE_ID),
|
||||
Map.of());
|
||||
}
|
||||
|
||||
private JobResult completedJobWithFile() {
|
||||
JobResult result = new JobResult();
|
||||
result.setJobId(JOB_ID);
|
||||
// completeWithSingleFile populates the resultFiles list, sets complete=true,
|
||||
// and sets completedAt - all required for the getJobResult single-file branch.
|
||||
result.completeWithSingleFile(FILE_ID, "out.pdf", "application/pdf", 7L);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full sticky-410 contract for {@code downloadFile} when the requested job is owned by a peer.
|
||||
* Asserts everything in one place (status, Retry-After header, payload shape, no
|
||||
* implementation-detail leak, metric incremented, storage never touched).
|
||||
*
|
||||
* <p>Other tests cover the edge cases independently (locally-owned, no JobStore entry, blank
|
||||
* owner, etc.) so a single failure here points at exactly one missing or broken contract
|
||||
* property.
|
||||
*/
|
||||
@Test
|
||||
@DisplayName(
|
||||
"downloadFile peer-owned → full sticky-410 contract"
|
||||
+ " (status + Retry-After + payload + metric + storage untouched)")
|
||||
void downloadFile_peerOwned_fullStickyContract() throws Exception {
|
||||
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
|
||||
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
|
||||
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(PEER_NODE)));
|
||||
|
||||
ResponseEntity<?> response = makeController().downloadFile(FILE_ID);
|
||||
|
||||
// 1. Status + Retry-After header (immediate-retry hint).
|
||||
assertEquals(HttpStatus.GONE, response.getStatusCode());
|
||||
assertEquals("0", response.getHeaders().getFirst("Retry-After"));
|
||||
|
||||
// 2. Payload shape: exactly { message, ownedBy, currentNode }, with no leaked secrets.
|
||||
assertInstanceOf(Map.class, response.getBody());
|
||||
Map<?, ?> body = (Map<?, ?>) response.getBody();
|
||||
assertEquals(3, body.size(), "exactly: message, ownedBy, currentNode");
|
||||
assertEquals(PEER_NODE, body.get("ownedBy"));
|
||||
assertEquals(LOCAL_NODE, body.get("currentNode"));
|
||||
assertNotNull(body.get("message"));
|
||||
assertTrue(((String) body.get("message")).toLowerCase().contains("retry"));
|
||||
assertNull(body.get("internalSecret"));
|
||||
assertNull(body.get("filePath"));
|
||||
|
||||
// 3. Operator-alert metric incremented exactly once.
|
||||
verify(stickyMissRecorder).recordStickyMiss();
|
||||
|
||||
// 4. Storage layer NEVER touched - bytes don't live here, so reading would be wrong.
|
||||
verify(fileStorage, never()).retrieveBytes(FILE_ID);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
// Happy-path ownership matrix: any non-peer signal (local owner, no entry, blank owner)
|
||||
// must produce a 200 from FileStorage with NO sticky-miss metric increment.
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
private static Stream<Arguments> downloadHappyPathScenarios() {
|
||||
return Stream.of(
|
||||
Arguments.of("locallyOwned", LOCAL_NODE, true),
|
||||
Arguments.of("noJobStoreEntry", null, false),
|
||||
Arguments.of("blankOwningNodeId", "", true));
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "downloadFile {0} -> 200, no sticky-miss")
|
||||
@MethodSource("downloadHappyPathScenarios")
|
||||
void downloadFile_happyPath_returnsOkAndNoMetric(
|
||||
String scenario, String ownerNodeId, boolean entryPresent) throws Exception {
|
||||
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
|
||||
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
|
||||
when(jobStore.get(JOB_ID))
|
||||
.thenReturn(
|
||||
entryPresent ? Optional.of(entryOwnedBy(ownerNodeId)) : Optional.empty());
|
||||
when(fileStorage.retrieveBytes(FILE_ID)).thenReturn("payload".getBytes());
|
||||
|
||||
ResponseEntity<?> response = makeController().downloadFile(FILE_ID);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode(), scenario);
|
||||
verify(fileStorage).retrieveBytes(FILE_ID);
|
||||
verify(stickyMissRecorder, never()).recordStickyMiss();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getJobResult: locally-owned single-file result → reads from FileStorage, 200 OK")
|
||||
void getJobResult_singleFile_locallyOwned_readsFromStorage() throws Exception {
|
||||
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
|
||||
when(taskManager.getJobResult(JOB_ID)).thenReturn(completedJobWithFile());
|
||||
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(LOCAL_NODE)));
|
||||
when(fileStorage.retrieveBytes(FILE_ID)).thenReturn("payload".getBytes());
|
||||
|
||||
ResponseEntity<?> response = makeController().getJobResult(JOB_ID);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
// Peer-owned 410 matrix across endpoints. Cross-cutting contract:
|
||||
// - status = 410, Retry-After = 0
|
||||
// - body.ownedBy = peer node, body.currentNode = local node
|
||||
// - sticky-miss metric incremented exactly once per request
|
||||
// The contract test above covers ALL of these properties for downloadFile in detail; this
|
||||
// parameterized matrix asserts the same status + ownedBy + metric signals on every endpoint.
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
private enum Endpoint {
|
||||
DOWNLOAD_FILE,
|
||||
GET_JOB_RESULT,
|
||||
GET_JOB_STATUS,
|
||||
CANCEL_JOB
|
||||
}
|
||||
|
||||
private static Stream<Arguments> peerOwned410Scenarios() {
|
||||
return Stream.of(
|
||||
Arguments.of(Endpoint.DOWNLOAD_FILE),
|
||||
Arguments.of(Endpoint.GET_JOB_RESULT),
|
||||
Arguments.of(Endpoint.GET_JOB_STATUS),
|
||||
Arguments.of(Endpoint.CANCEL_JOB));
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} peer-owned -> 410, ownedBy=peer, metric++")
|
||||
@MethodSource("peerOwned410Scenarios")
|
||||
void endpoint_peerOwned_returns410(Endpoint endpoint) throws Exception {
|
||||
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
|
||||
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(PEER_NODE)));
|
||||
// Endpoint-specific wiring: the per-endpoint code path needs different mock setup
|
||||
// before it reaches the sticky-410 guard.
|
||||
switch (endpoint) {
|
||||
case DOWNLOAD_FILE -> when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
|
||||
case GET_JOB_RESULT ->
|
||||
when(taskManager.getJobResult(JOB_ID)).thenReturn(completedJobWithFile());
|
||||
case GET_JOB_STATUS -> when(taskManager.getJobResult(JOB_ID)).thenReturn(null);
|
||||
case CANCEL_JOB -> {
|
||||
when(jobQueue.isJobQueued(JOB_ID)).thenReturn(false);
|
||||
when(taskManager.getJobResult(JOB_ID)).thenReturn(null);
|
||||
}
|
||||
}
|
||||
|
||||
ResponseEntity<?> response =
|
||||
switch (endpoint) {
|
||||
case DOWNLOAD_FILE -> makeController().downloadFile(FILE_ID);
|
||||
case GET_JOB_RESULT -> makeController().getJobResult(JOB_ID);
|
||||
case GET_JOB_STATUS -> makeController().getJobStatus(JOB_ID);
|
||||
case CANCEL_JOB -> makeController().cancelJob(JOB_ID);
|
||||
};
|
||||
|
||||
assertEquals(HttpStatus.GONE, response.getStatusCode());
|
||||
Map<?, ?> body = (Map<?, ?>) response.getBody();
|
||||
assertEquals(PEER_NODE, body.get("ownedBy"));
|
||||
assertEquals(LOCAL_NODE, body.get("currentNode"));
|
||||
verify(stickyMissRecorder).recordStickyMiss();
|
||||
// Storage / mutation must never be touched by a peer-routed request.
|
||||
verify(fileStorage, never()).retrieveBytes(FILE_ID);
|
||||
if (endpoint == Endpoint.CANCEL_JOB) {
|
||||
verify(taskManager, never()).setError(JOB_ID, "Job was cancelled by user");
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
// Unknown-job 404 matrix: when neither TaskManager nor JobStore knows the jobId, the
|
||||
// controller must return 404 (not 410) and must NOT count it as a sticky miss.
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
private static Stream<Arguments> unknownJob404Scenarios() {
|
||||
return Stream.of(Arguments.of(Endpoint.GET_JOB_STATUS), Arguments.of(Endpoint.CANCEL_JOB));
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} unknown jobId -> 404 (not 410), no metric")
|
||||
@MethodSource("unknownJob404Scenarios")
|
||||
void endpoint_unknownJob_returns404(Endpoint endpoint) {
|
||||
when(taskManager.getJobResult(JOB_ID)).thenReturn(null);
|
||||
when(jobStore.get(JOB_ID)).thenReturn(Optional.empty());
|
||||
if (endpoint == Endpoint.CANCEL_JOB) {
|
||||
when(jobQueue.isJobQueued(JOB_ID)).thenReturn(false);
|
||||
}
|
||||
|
||||
ResponseEntity<?> response =
|
||||
switch (endpoint) {
|
||||
case GET_JOB_STATUS -> makeController().getJobStatus(JOB_ID);
|
||||
case CANCEL_JOB -> makeController().cancelJob(JOB_ID);
|
||||
default -> throw new IllegalArgumentException(endpoint.name());
|
||||
};
|
||||
|
||||
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
|
||||
verify(stickyMissRecorder, never()).recordStickyMiss();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
// Single-instance / null-bean wiring: no NPE, no 410, no metric. These test SPECIFIC
|
||||
// wiring permutations and so stay as discrete tests rather than rows.
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("Single-instance install (no ClusterBackplane bean): no 410, no NPE")
|
||||
void singleInstance_noClusterBackplane_noGoneResponse() throws Exception {
|
||||
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
|
||||
when(fileStorage.retrieveBytes(FILE_ID)).thenReturn("payload".getBytes());
|
||||
|
||||
ResponseEntity<?> response = makeController(null, jobStore).downloadFile(FILE_ID);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
verify(fileStorage).retrieveBytes(FILE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Single-instance install (no JobStore bean): no 410, no NPE")
|
||||
void singleInstance_noJobStore_noGoneResponse() throws Exception {
|
||||
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
|
||||
when(fileStorage.retrieveBytes(FILE_ID)).thenReturn("payload".getBytes());
|
||||
|
||||
ResponseEntity<?> response = makeController(clusterBackplane, null).downloadFile(FILE_ID);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
verify(fileStorage).retrieveBytes(FILE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Single-instance (no StickyMissRecorder bean) → no NPE, still 200 OK")
|
||||
void noStickyMissRecorder_works() throws Exception {
|
||||
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
|
||||
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
|
||||
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(LOCAL_NODE)));
|
||||
when(fileStorage.retrieveBytes(FILE_ID)).thenReturn("payload".getBytes());
|
||||
|
||||
JobController c = makeController();
|
||||
ReflectionTestUtils.setField(c, "stickyMissRecorder", null);
|
||||
|
||||
ResponseEntity<?> response = c.downloadFile(FILE_ID);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"cluster-mode but localNodeId is null → no NPE; 410 because owner is set and"
|
||||
+ " differs from blank")
|
||||
void clusterBackplanePresent_butLocalNodeIdNull_falsBackGracefully() throws Exception {
|
||||
when(clusterBackplane.localNodeId()).thenReturn(null);
|
||||
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
|
||||
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(PEER_NODE)));
|
||||
|
||||
// We still 410: owner is "node-peer", local is null → they don't match. Rather than
|
||||
// silently 200-from-wrong-disk (which would serve garbage), we surface the mismatch.
|
||||
ResponseEntity<?> response = makeController().downloadFile(FILE_ID);
|
||||
|
||||
assertEquals(HttpStatus.GONE, response.getStatusCode());
|
||||
Map<?, ?> body = (Map<?, ?>) response.getBody();
|
||||
assertEquals("", body.get("currentNode"), "blank when localNodeId is null");
|
||||
assertEquals(PEER_NODE, body.get("ownedBy"));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
// Ownership-service interaction: sticky-410 must take precedence over per-user auth so
|
||||
// we never 403 on a peer-owned resource (which would leak existence + defeat the redirect).
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("Owner returns 410 even when JobOwnershipService allows access (orthogonal)")
|
||||
void ownershipService_passes_butStickyStillReturns410() throws Exception {
|
||||
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
|
||||
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
|
||||
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(PEER_NODE)));
|
||||
lenient().when(jobOwnershipService.validateJobAccess(JOB_ID)).thenReturn(true);
|
||||
|
||||
JobController c = makeController();
|
||||
ReflectionTestUtils.setField(c, "jobOwnershipService", jobOwnershipService);
|
||||
ResponseEntity<?> response = c.downloadFile(FILE_ID);
|
||||
|
||||
// OwnershipService is about *user* auth; sticky-410 is about *node* topology.
|
||||
// Both must pass for a 200, and node-ownership is checked first so a non-owner
|
||||
// never even runs the user-auth check.
|
||||
assertEquals(HttpStatus.GONE, response.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"downloadFile: peer-owned + ownership-denied → 410 (NOT 403) so we don't leak"
|
||||
+ " file existence")
|
||||
void downloadFile_peerOwned_ownershipDenied_returns410NotForbidden() throws Exception {
|
||||
// Guard ordering: sticky-410 must run before user-auth. If user-auth ran first, a
|
||||
// peer-owned-file request on the wrong node would fail user-auth (this node cannot
|
||||
// verify access to a job it does not own) and return 403, which leaks file existence
|
||||
// AND defeats the sticky-410 design (the frontend can't retry-with-affinity off a 403).
|
||||
// Guard first so the user is redirected to the owner where the real auth check happens.
|
||||
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
|
||||
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
|
||||
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(PEER_NODE)));
|
||||
lenient().when(jobOwnershipService.validateJobAccess(JOB_ID)).thenReturn(false);
|
||||
|
||||
JobController c = makeController();
|
||||
ReflectionTestUtils.setField(c, "jobOwnershipService", jobOwnershipService);
|
||||
ResponseEntity<?> response = c.downloadFile(FILE_ID);
|
||||
|
||||
assertEquals(HttpStatus.GONE, response.getStatusCode());
|
||||
Map<?, ?> body = (Map<?, ?>) response.getBody();
|
||||
assertEquals(PEER_NODE, body.get("ownedBy"));
|
||||
// Crucially: never reached fileStorage, never returned 403.
|
||||
verify(fileStorage, never()).retrieveBytes(FILE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"getJobStatus: peer-owned + ownership-denied → 410 (NOT 403) so we don't leak"
|
||||
+ " job existence")
|
||||
void getJobStatus_peerOwned_ownershipDenied_returns410NotForbidden() {
|
||||
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
|
||||
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(PEER_NODE)));
|
||||
lenient().when(jobOwnershipService.validateJobAccess(JOB_ID)).thenReturn(false);
|
||||
|
||||
JobController c = makeController();
|
||||
ReflectionTestUtils.setField(c, "jobOwnershipService", jobOwnershipService);
|
||||
ResponseEntity<?> response = c.getJobStatus(JOB_ID);
|
||||
|
||||
assertEquals(HttpStatus.GONE, response.getStatusCode());
|
||||
Map<?, ?> body = (Map<?, ?>) response.getBody();
|
||||
assertEquals(PEER_NODE, body.get("ownedBy"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"cancelJob: peer-owned + ownership-denied → 410 (NOT 403) so we don't leak job"
|
||||
+ " existence")
|
||||
void cancelJob_peerOwned_ownershipDenied_returns410NotForbidden() {
|
||||
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
|
||||
when(jobQueue.isJobQueued(JOB_ID)).thenReturn(false);
|
||||
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(PEER_NODE)));
|
||||
lenient().when(jobOwnershipService.validateJobAccess(JOB_ID)).thenReturn(false);
|
||||
|
||||
JobController c = makeController();
|
||||
ReflectionTestUtils.setField(c, "jobOwnershipService", jobOwnershipService);
|
||||
ResponseEntity<?> response = c.cancelJob(JOB_ID);
|
||||
|
||||
assertEquals(HttpStatus.GONE, response.getStatusCode());
|
||||
Map<?, ?> body = (Map<?, ?>) response.getBody();
|
||||
assertEquals(PEER_NODE, body.get("ownedBy"));
|
||||
verify(taskManager, never()).setError(JOB_ID, "Job was cancelled by user");
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
// S2: JobStore lookup hardening (local cache + Valkey-fault graceful-degrade)
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"guardNonOwner caches JobStore.get within TTL window: second call same jobId hits"
|
||||
+ " cache, not Valkey")
|
||||
void guardNonOwner_cachesJobStoreLookupWithinTtl() throws Exception {
|
||||
when(clusterBackplane.localNodeId()).thenReturn(LOCAL_NODE);
|
||||
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
|
||||
when(jobStore.get(JOB_ID)).thenReturn(Optional.of(entryOwnedBy(LOCAL_NODE)));
|
||||
when(fileStorage.retrieveBytes(FILE_ID)).thenReturn("payload".getBytes());
|
||||
|
||||
JobController c = makeController();
|
||||
c.downloadFile(FILE_ID);
|
||||
c.downloadFile(FILE_ID);
|
||||
c.downloadFile(FILE_ID);
|
||||
|
||||
// Three downloads of the same fileId == one HGETALL. Without the cache this would have
|
||||
// been three Valkey round-trips on the hot download path.
|
||||
verify(jobStore, times(1)).get(JOB_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"guardNonOwner: JobStore.get throws (Valkey timeout) → falls through to local-disk"
|
||||
+ " path, no 500 leaks to caller")
|
||||
void guardNonOwner_jobStoreException_fallsThroughToLocalPath() throws Exception {
|
||||
when(taskManager.findJobKeyByFileId(FILE_ID)).thenReturn(JOB_ID);
|
||||
// Simulate a Valkey timeout. spring-data-redis surfaces these as runtime exceptions
|
||||
// wrapping Lettuce errors; the controller must not let any RuntimeException leak out.
|
||||
when(jobStore.get(JOB_ID)).thenThrow(new RuntimeException("Valkey command timeout"));
|
||||
when(fileStorage.retrieveBytes(FILE_ID)).thenReturn("payload".getBytes());
|
||||
|
||||
ResponseEntity<?> response = makeController().downloadFile(FILE_ID);
|
||||
|
||||
// The download must succeed via the local-disk path. A brief Valkey blip cannot break
|
||||
// every download attempt with 500 - we cleanly degrade to single-node behavior.
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
verify(fileStorage).retrieveBytes(FILE_ID);
|
||||
// The exception did NOT count as a sticky-miss (it wasn't one; we just couldn't see).
|
||||
verify(stickyMissRecorder, never()).recordStickyMiss();
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.common.cluster.ClusterBackplane;
|
||||
import stirling.software.common.cluster.JobStore;
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
@@ -37,6 +39,10 @@ class JobControllerTest {
|
||||
|
||||
@Mock private JobOwnershipService jobOwnershipService;
|
||||
|
||||
@Mock private ClusterBackplane clusterBackplane;
|
||||
|
||||
@Mock private JobStore jobStore;
|
||||
|
||||
private MockHttpSession session;
|
||||
|
||||
@InjectMocks private JobController controller;
|
||||
|
||||
@@ -123,8 +123,6 @@ SwaggerDoc.json
|
||||
*.tar.gz
|
||||
*.rar
|
||||
*.db
|
||||
# Whitelist the H2 fixtures that feed the version-migration CI smoke test.
|
||||
!src/test/resources/db-migration-fixtures/*.mv.db
|
||||
/build
|
||||
/app/proprietary/build/
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ repositories {
|
||||
|
||||
ext {
|
||||
jwtVersion = '0.13.0'
|
||||
awsSdkVersion = '2.44.12'
|
||||
testcontainersMinioVersion = '1.21.4'
|
||||
}
|
||||
|
||||
bootRun {
|
||||
@@ -46,6 +44,7 @@ dependencies {
|
||||
api 'org.springframework:spring-jdbc'
|
||||
api 'org.springframework:spring-webmvc'
|
||||
api 'org.springframework.session:spring-session-core'
|
||||
implementation 'org.springframework.session:spring-session-data-redis'
|
||||
api "org.springframework.security:spring-security-core:$springSecuritySamlVersion"
|
||||
api "org.springframework.security:spring-security-saml2-service-provider:$springSecuritySamlVersion"
|
||||
api 'org.springframework.boot:spring-boot-starter-jetty'
|
||||
@@ -55,8 +54,13 @@ dependencies {
|
||||
api 'org.springframework.boot:spring-boot-starter-mail'
|
||||
api 'org.springframework.boot:spring-boot-starter-cache'
|
||||
api 'com.github.ben-manes.caffeine:caffeine'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.46'
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.18.0'
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0'
|
||||
// Lettuce-backed Bucket4j ProxyManager used by ValkeyRateLimitStore for cluster-wide
|
||||
// token-bucket rate limiting (parity with in-process Bucket4j semantics; no fixed-window
|
||||
// boundary doubling).
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-lettuce:8.19.0'
|
||||
|
||||
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
|
||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
||||
@@ -74,12 +78,10 @@ dependencies {
|
||||
exclude group: 'org.opensaml', module: 'opensaml-core'
|
||||
}
|
||||
|
||||
implementation "software.amazon.awssdk:s3:$awsSdkVersion"
|
||||
implementation "software.amazon.awssdk:url-connection-client:$awsSdkVersion"
|
||||
|
||||
testImplementation "org.testcontainers:minio:$testcontainersMinioVersion"
|
||||
testImplementation "org.testcontainers:junit-jupiter:$testcontainersMinioVersion"
|
||||
testImplementation "org.testcontainers:localstack:$testcontainersMinioVersion"
|
||||
// Testcontainers: spins up a real Valkey for LiveValkeyIntegrationTest in CI without
|
||||
// needing a manually-started instance. Tests skip cleanly when Docker is unavailable.
|
||||
testImplementation 'org.testcontainers:testcontainers:1.21.4'
|
||||
testImplementation 'org.testcontainers:junit-jupiter:1.21.4'
|
||||
}
|
||||
|
||||
tasks.register('prepareKotlinBuildScriptModel') {}
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package stirling.software.proprietary.cluster;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Runtime license gate for cluster mode. Cluster mode requires a SERVER or ENTERPRISE license; the
|
||||
* SaaS flavor bypasses (no {@code runningProOrHigher} bean is published). Fires before any Valkey
|
||||
* bean construction via {@link Ordered#HIGHEST_PRECEDENCE}.
|
||||
*
|
||||
* <p>There is no testing/development bypass. Live e2e tests that need cluster mode must inject a
|
||||
* valid {@code stirling.premium.key} for a test-tier SERVER/ENTERPRISE license. Unit tests stub the
|
||||
* {@code runningProOrHigher} bean directly.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
@Slf4j
|
||||
public class ClusterLicenseGate {
|
||||
|
||||
@Autowired(required = false)
|
||||
@Qualifier("runningProOrHigher")
|
||||
private Boolean runningProOrHigher;
|
||||
|
||||
@PostConstruct
|
||||
void verifyLicense() {
|
||||
if (runningProOrHigher == null) {
|
||||
return; // saas flavor - licensed via Stripe elsewhere
|
||||
}
|
||||
if (!runningProOrHigher) {
|
||||
throw new IllegalStateException(
|
||||
"Cluster mode (cluster.enabled=true) requires a SERVER or"
|
||||
+ " ENTERPRISE license. Configure stirling.premium.key with a valid"
|
||||
+ " license key (contact sales@stirlingpdf.com to obtain one), or set"
|
||||
+ " cluster.enabled=false.");
|
||||
}
|
||||
log.info("Cluster license gate: SERVER/ENTERPRISE license verified, cluster mode allowed.");
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package stirling.software.proprietary.cluster;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Gauge;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
|
||||
import stirling.software.common.cluster.StickyMissRecorder;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Cluster operation metrics exposed via {@code /actuator/prometheus}. Registered only when cluster
|
||||
* mode is on.
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
|
||||
public class ClusterMetrics implements StickyMissRecorder {
|
||||
|
||||
private final MeterRegistry registry;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
private final Counter stickyMissTotal;
|
||||
private final Counter rateLimitRejected;
|
||||
private final Timer backplaneLatency;
|
||||
private final Timer jobWaitSeconds;
|
||||
|
||||
// Per-lane queue depth gauges. Lanes are a fixed enum (FAST, SLOW, AI), so we register all
|
||||
// three eagerly so dashboards never have a missing series.
|
||||
private static final List<String> KNOWN_LANES = List.of("FAST", "SLOW", "AI");
|
||||
private final ConcurrentHashMap<String, AtomicLong> queueDepth = new ConcurrentHashMap<>();
|
||||
|
||||
// In-flight job count for THIS node.
|
||||
private final AtomicLong jobsInflight = new AtomicLong();
|
||||
|
||||
public ClusterMetrics(MeterRegistry registry, ApplicationProperties applicationProperties) {
|
||||
this.registry = registry;
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.stickyMissTotal =
|
||||
Counter.builder("stirling_cluster_sticky_miss_total")
|
||||
.description(
|
||||
"Sticky-session misses: a download for a job whose result lives on"
|
||||
+ " a peer node landed on this node. High sustained value means"
|
||||
+ " LB affinity is broken.")
|
||||
.register(registry);
|
||||
this.rateLimitRejected =
|
||||
Counter.builder("stirling_cluster_ratelimit_rejected_total")
|
||||
.description("Cluster-wide rate limit rejections")
|
||||
.register(registry);
|
||||
this.backplaneLatency =
|
||||
Timer.builder("stirling_cluster_backplane_latency_seconds")
|
||||
.description("Backplane round-trip latency")
|
||||
.register(registry);
|
||||
this.jobWaitSeconds =
|
||||
Timer.builder("stirling_cluster_job_wait_seconds")
|
||||
.description("Time jobs spend queued before execution")
|
||||
.register(registry);
|
||||
Gauge.builder("stirling_cluster_jobs_inflight", jobsInflight, AtomicLong::doubleValue)
|
||||
.description("Jobs currently in flight on this node")
|
||||
.tag("node", applicationProperties.getCluster().resolvedNodeId())
|
||||
.register(registry);
|
||||
for (String lane : KNOWN_LANES) {
|
||||
ensureLaneGauge(lane);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment when {@code JobController} returns 410 Gone because the requested job's owner is a
|
||||
* peer node. Surfaces to {@code stirling_cluster_sticky_miss_total}.
|
||||
*/
|
||||
@Override
|
||||
public void recordStickyMiss() {
|
||||
stickyMissTotal.increment();
|
||||
}
|
||||
|
||||
public void recordRateLimitReject() {
|
||||
rateLimitRejected.increment();
|
||||
}
|
||||
|
||||
public Timer backplaneLatency() {
|
||||
return backplaneLatency;
|
||||
}
|
||||
|
||||
public Timer jobWaitSeconds() {
|
||||
return jobWaitSeconds;
|
||||
}
|
||||
|
||||
public void incrementInflight() {
|
||||
jobsInflight.incrementAndGet();
|
||||
}
|
||||
|
||||
public void decrementInflight() {
|
||||
jobsInflight.decrementAndGet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish (or update) the queue depth gauge for {@code lane}. Idempotent - safe to call hot.
|
||||
* Known lanes (FAST, SLOW, AI) are pre-registered at construction; unknown lanes register on
|
||||
* first call.
|
||||
*/
|
||||
public void setQueueDepth(String lane, long depth) {
|
||||
ensureLaneGauge(lane).set(depth);
|
||||
}
|
||||
|
||||
private AtomicLong ensureLaneGauge(String lane) {
|
||||
return queueDepth.computeIfAbsent(
|
||||
lane,
|
||||
l -> {
|
||||
AtomicLong holder = new AtomicLong();
|
||||
Gauge.builder("stirling_cluster_queue_depth", holder, AtomicLong::doubleValue)
|
||||
.description("Pending items in a job queue lane")
|
||||
.tag("lane", l)
|
||||
.register(registry);
|
||||
return holder;
|
||||
});
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package stirling.software.proprietary.cluster;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.ClusterNode;
|
||||
import stirling.software.common.cluster.InstanceRegistry;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Cluster;
|
||||
|
||||
/**
|
||||
* Registers the local node with {@link InstanceRegistry} on startup, refreshes the entry at 1/3 of
|
||||
* the TTL, and deregisters cleanly on shutdown.
|
||||
*
|
||||
* <p>Implements {@link SmartLifecycle} with {@code getPhase() == Integer.MAX_VALUE} so Spring tears
|
||||
* this bean down before {@code LettuceConnectionFactory} - deregister therefore runs while the
|
||||
* Valkey connection is still alive.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
|
||||
public class ClusterNodeBootstrap implements SmartLifecycle {
|
||||
|
||||
/** TTL of the node entry in the registry. Set to 3x the heartbeat interval. */
|
||||
private final Duration heartbeatTtl;
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final InstanceRegistry instanceRegistry;
|
||||
|
||||
@Value("${server.port:8080}")
|
||||
private int serverPort;
|
||||
|
||||
private volatile String nodeId;
|
||||
private volatile String internalAddress;
|
||||
private volatile boolean running = false;
|
||||
|
||||
public ClusterNodeBootstrap(
|
||||
ApplicationProperties applicationProperties, InstanceRegistry instanceRegistry) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.instanceRegistry = instanceRegistry;
|
||||
Cluster cluster = applicationProperties.getCluster();
|
||||
long heartbeatMs =
|
||||
cluster.getNode() == null ? 10_000L : cluster.getNode().getHeartbeatIntervalMs();
|
||||
// TTL = 3x heartbeat: tolerate one missed tick before the node drops out of the registry.
|
||||
this.heartbeatTtl = Duration.ofMillis(heartbeatMs * 3);
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void registerOnStartup() {
|
||||
nodeId = applicationProperties.getCluster().resolvedNodeId();
|
||||
internalAddress = resolveInternalAddress();
|
||||
registerSelf("register");
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${cluster.node.heartbeat-interval-ms:10000}")
|
||||
public void heartbeat() {
|
||||
// Heartbeat-after-stop race: SmartLifecycle.stop() deregisters, but the @Scheduled
|
||||
// tick keeps firing during a slow drain. Without this guard, the next tick re-registers
|
||||
// the dead node and the entry resurfaces in the registry until TTL expiry.
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
if (nodeId == null) {
|
||||
return; // not yet registered (startup race)
|
||||
}
|
||||
// Self-healing: register() is idempotent and re-populates every field, so a wiped
|
||||
// Valkey (FLUSHALL, hash eviction) recovers on the next tick without operator action.
|
||||
registerSelf("heartbeat");
|
||||
}
|
||||
|
||||
private void registerSelf(String reason) {
|
||||
try {
|
||||
instanceRegistry.register(
|
||||
new ClusterNode(nodeId, internalAddress, Instant.now(), role()), heartbeatTtl);
|
||||
if ("register".equals(reason)) {
|
||||
log.info(
|
||||
"Cluster node registered: nodeId={}, internalAddress={}, role={}, ttl={}s",
|
||||
nodeId,
|
||||
internalAddress,
|
||||
role(),
|
||||
heartbeatTtl.toSeconds());
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
log.debug("Cluster {} failed for {}", reason, nodeId, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- SmartLifecycle (see class javadoc for ordering rationale) ----------
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
running = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
running = false;
|
||||
if (nodeId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
instanceRegistry.deregister(nodeId);
|
||||
log.info("Cluster node deregistered: {}", nodeId);
|
||||
} catch (RuntimeException e) {
|
||||
// Registry entry will TTL-expire within heartbeatTtl anyway.
|
||||
log.warn(
|
||||
"Cluster deregister failed for {} (will TTL-expire within {}s): {}",
|
||||
nodeId,
|
||||
heartbeatTtl.toSeconds(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPhase() {
|
||||
return Integer.MAX_VALUE; // stopped first; LettuceConnectionFactory's phase is 0
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAutoStartup() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the address peers should hit. Order: explicit config -> {@code POD_IP} env (K8s
|
||||
* downward API) -> JDK hostname -> fail loud (never silently fall back to a loopback).
|
||||
*
|
||||
* <p>Scheme is taken from {@code cluster.node.scheme} (default {@code http}). Set to {@code
|
||||
* https} when nodes terminate TLS themselves; leave as {@code http} when an upstream LB
|
||||
* terminates TLS and intra-cluster traffic is plain HTTP.
|
||||
*/
|
||||
private String resolveInternalAddress() {
|
||||
Cluster cluster = applicationProperties.getCluster();
|
||||
String configured =
|
||||
cluster.getNode() == null ? null : cluster.getNode().getInternalAddress();
|
||||
if (configured != null && !configured.isBlank()) {
|
||||
return ensurePort(configured);
|
||||
}
|
||||
String podIp = System.getenv("POD_IP");
|
||||
if (podIp != null && !podIp.isBlank()) {
|
||||
return scheme() + "://" + podIp + ":" + serverPort;
|
||||
}
|
||||
try {
|
||||
return scheme()
|
||||
+ "://"
|
||||
+ InetAddress.getLocalHost().getHostAddress()
|
||||
+ ":"
|
||||
+ serverPort;
|
||||
} catch (UnknownHostException e) {
|
||||
throw new IllegalStateException(
|
||||
"Could not resolve this host's address for cluster registration; set"
|
||||
+ " cluster.node.internal-address explicitly (or set POD_IP"
|
||||
+ " in the Kubernetes downward API).",
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private String ensurePort(String addr) {
|
||||
if (addr.startsWith("http://") || addr.startsWith("https://")) {
|
||||
return addr;
|
||||
}
|
||||
if (addr.contains(":")) {
|
||||
return scheme() + "://" + addr;
|
||||
}
|
||||
return scheme() + "://" + addr + ":" + serverPort;
|
||||
}
|
||||
|
||||
private String scheme() {
|
||||
Cluster cluster = applicationProperties.getCluster();
|
||||
if (cluster.getNode() == null
|
||||
|| cluster.getNode().getScheme() == null
|
||||
|| cluster.getNode().getScheme().isBlank()) {
|
||||
return "http";
|
||||
}
|
||||
String s = cluster.getNode().getScheme().trim().toLowerCase(Locale.ROOT);
|
||||
return "https".equals(s) ? "https" : "http";
|
||||
}
|
||||
|
||||
private String role() {
|
||||
Cluster.NodeRole r = applicationProperties.getCluster().resolvedRole();
|
||||
return r == null ? "BOTH" : r.name();
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package stirling.software.proprietary.cluster;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.RedisSerializer;
|
||||
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
|
||||
import org.springframework.session.web.http.CookieSerializer;
|
||||
import org.springframework.session.web.http.DefaultCookieSerializer;
|
||||
|
||||
/**
|
||||
* Enables Spring Session backed by Valkey when cluster mode is on AND a Lettuce connection factory
|
||||
* exists (backplane=valkey). {@link ConditionalOnBean} prevents {@code @EnableRedisHttpSession}
|
||||
* from wiring its filter before the required connection factory is present; without it the bean
|
||||
* graph fails with "No qualifying bean of type 'SessionRepository'".
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
|
||||
@ConditionalOnBean(LettuceConnectionFactory.class)
|
||||
@EnableRedisHttpSession
|
||||
public class ClusterSessionConfiguration {
|
||||
|
||||
/**
|
||||
* The bean name {@code springSessionDefaultRedisSerializer} is the exact hook Spring Session
|
||||
* uses to override JDK serialization. JDK serialization is a deserialization-gadget RCE surface
|
||||
* for anyone with Valkey write access; Jackson JSON is not.
|
||||
*
|
||||
* <p>Migrate to {@code GenericJacksonJsonRedisSerializer} when the Spring Session reference doc
|
||||
* does (https://docs.spring.io/spring-session/reference/spring-security.html#config-redis).
|
||||
*/
|
||||
@Bean
|
||||
@SuppressWarnings(
|
||||
"removal") // GenericJackson2JsonRedisSerializer is the documented recipe name; migrate
|
||||
// when upstream does
|
||||
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
|
||||
return new GenericJackson2JsonRedisSerializer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Harden the Spring Session cookie: Spring Session omits {@code Secure} and {@code SameSite} by
|
||||
* default, leaving the session id susceptible to plaintext leakage and CSRF. {@code Lax} allows
|
||||
* top-level navigations (login redirects) while blocking cross-site sub-resource requests.
|
||||
* HttpOnly is on by default - asserted in the regression test.
|
||||
*/
|
||||
@Bean
|
||||
public CookieSerializer cookieSerializer() {
|
||||
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
|
||||
serializer.setUseSecureCookie(true);
|
||||
serializer.setSameSite("Lax");
|
||||
return serializer;
|
||||
}
|
||||
}
|
||||
-200
@@ -1,200 +0,0 @@
|
||||
package stirling.software.proprietary.cluster.s3;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.UnknownHostException;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.core.checksums.RequestChecksumCalculation;
|
||||
import software.amazon.awssdk.core.checksums.ResponseChecksumValidation;
|
||||
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3ClientBuilder;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||
|
||||
/**
|
||||
* Shared factory for {@link S3Client} and {@link S3Presigner} instances used by both {@code
|
||||
* S3StorageProvider} and {@code S3FileStore}, so endpoint/region/credentials wiring lives in
|
||||
* exactly one place.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class S3Clients {
|
||||
|
||||
private S3Clients() {}
|
||||
|
||||
/** Paired client and presigner with coordinated lifecycle. */
|
||||
public record Bundle(S3Client client, S3Presigner presigner) implements AutoCloseable {
|
||||
@Override
|
||||
public void close() {
|
||||
try {
|
||||
presigner.close();
|
||||
} catch (Exception e) {
|
||||
log.warn("Error closing S3 presigner", e);
|
||||
}
|
||||
try {
|
||||
client.close();
|
||||
} catch (Exception e) {
|
||||
log.warn("Error closing S3 client", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a client+presigner pair from the shared S3 config block. */
|
||||
public static Bundle build(ApplicationProperties.Storage.S3 cfg, String usage) {
|
||||
if (cfg == null) {
|
||||
throw new IllegalStateException(
|
||||
usage + " requires storage.s3.* configuration to be set");
|
||||
}
|
||||
if (cfg.getBucket() == null || cfg.getBucket().isBlank()) {
|
||||
throw new IllegalStateException(usage + " requires storage.s3.bucket to be set");
|
||||
}
|
||||
String region =
|
||||
cfg.getRegion() == null || cfg.getRegion().isBlank()
|
||||
? "us-east-1"
|
||||
: cfg.getRegion();
|
||||
|
||||
S3Configuration s3Configuration =
|
||||
S3Configuration.builder().pathStyleAccessEnabled(cfg.isPathStyleAccess()).build();
|
||||
|
||||
RequestChecksumCalculation requestChecksum =
|
||||
parseRequestChecksum(cfg.getRequestChecksumCalculation());
|
||||
ResponseChecksumValidation responseChecksum =
|
||||
parseResponseChecksum(cfg.getResponseChecksumValidation());
|
||||
|
||||
S3ClientBuilder clientBuilder =
|
||||
S3Client.builder()
|
||||
.httpClient(UrlConnectionHttpClient.create())
|
||||
.region(Region.of(region))
|
||||
.serviceConfiguration(s3Configuration)
|
||||
.requestChecksumCalculation(requestChecksum)
|
||||
.responseChecksumValidation(responseChecksum);
|
||||
|
||||
S3Presigner.Builder presignerBuilder =
|
||||
S3Presigner.builder()
|
||||
.region(Region.of(region))
|
||||
.serviceConfiguration(s3Configuration);
|
||||
|
||||
if (cfg.getEndpoint() != null && !cfg.getEndpoint().isBlank()) {
|
||||
URI endpoint;
|
||||
try {
|
||||
endpoint = new URI(cfg.getEndpoint());
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IllegalStateException(
|
||||
"Invalid storage.s3.endpoint: " + cfg.getEndpoint(), e);
|
||||
}
|
||||
validateEndpointHost(endpoint, cfg.isAllowPrivateEndpoints());
|
||||
clientBuilder.endpointOverride(endpoint);
|
||||
presignerBuilder.endpointOverride(endpoint);
|
||||
}
|
||||
|
||||
boolean hasStaticCreds =
|
||||
cfg.getAccessKey() != null
|
||||
&& !cfg.getAccessKey().isBlank()
|
||||
&& cfg.getSecretKey() != null
|
||||
&& !cfg.getSecretKey().isBlank();
|
||||
if (hasStaticCreds) {
|
||||
AwsBasicCredentials credentials =
|
||||
AwsBasicCredentials.create(cfg.getAccessKey(), cfg.getSecretKey());
|
||||
StaticCredentialsProvider provider = StaticCredentialsProvider.create(credentials);
|
||||
clientBuilder.credentialsProvider(provider);
|
||||
presignerBuilder.credentialsProvider(provider);
|
||||
} else {
|
||||
clientBuilder.credentialsProvider(DefaultCredentialsProvider.create());
|
||||
presignerBuilder.credentialsProvider(DefaultCredentialsProvider.create());
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Configured S3 {}: bucket={}, region={}, endpoint={}, pathStyle={}",
|
||||
usage,
|
||||
cfg.getBucket(),
|
||||
region,
|
||||
cfg.getEndpoint() == null || cfg.getEndpoint().isBlank()
|
||||
? "<aws-default>"
|
||||
: cfg.getEndpoint(),
|
||||
cfg.isPathStyleAccess());
|
||||
|
||||
return new Bundle(clientBuilder.build(), presignerBuilder.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Block SSRF via the S3 endpoint setting. An admin who can edit config could otherwise point
|
||||
* the SDK at the cloud metadata service (e.g. {@code http://169.254.169.254/}) and exfiltrate
|
||||
* instance-role credentials. Reject any endpoint whose host resolves to a loopback, link-local,
|
||||
* or RFC1918 private address unless the operator has explicitly opted in via {@code
|
||||
* storage.s3.allow-private-endpoints=true}.
|
||||
*/
|
||||
static void validateEndpointHost(URI endpoint, boolean allowPrivate) {
|
||||
if (allowPrivate) {
|
||||
return;
|
||||
}
|
||||
String host = endpoint.getHost();
|
||||
if (host == null || host.isBlank()) {
|
||||
throw new IllegalStateException("storage.s3.endpoint must include a host: " + endpoint);
|
||||
}
|
||||
InetAddress[] addresses;
|
||||
try {
|
||||
addresses = InetAddress.getAllByName(host);
|
||||
} catch (UnknownHostException e) {
|
||||
throw new IllegalStateException(
|
||||
"Unable to resolve storage.s3.endpoint host '" + host + "'", e);
|
||||
}
|
||||
for (InetAddress address : addresses) {
|
||||
if (isPrivateOrLocal(address)) {
|
||||
throw new IllegalStateException(
|
||||
"storage.s3.endpoint host '"
|
||||
+ host
|
||||
+ "' resolves to private/link-local address "
|
||||
+ address.getHostAddress()
|
||||
+ "; set storage.s3.allow-private-endpoints=true to opt in"
|
||||
+ " (e.g. for MinIO or in-cluster S3).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isPrivateOrLocal(InetAddress address) {
|
||||
return address.isLoopbackAddress()
|
||||
|| address.isLinkLocalAddress()
|
||||
|| address.isSiteLocalAddress()
|
||||
|| address.isAnyLocalAddress()
|
||||
|| address.isMulticastAddress();
|
||||
}
|
||||
|
||||
static RequestChecksumCalculation parseRequestChecksum(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return RequestChecksumCalculation.WHEN_SUPPORTED;
|
||||
}
|
||||
try {
|
||||
return RequestChecksumCalculation.valueOf(
|
||||
value.trim().toUpperCase(java.util.Locale.ROOT));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
log.warn(
|
||||
"Unknown storage.s3.request-checksum-calculation value '{}', falling back to WHEN_SUPPORTED",
|
||||
value);
|
||||
return RequestChecksumCalculation.WHEN_SUPPORTED;
|
||||
}
|
||||
}
|
||||
|
||||
static ResponseChecksumValidation parseResponseChecksum(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return ResponseChecksumValidation.WHEN_SUPPORTED;
|
||||
}
|
||||
try {
|
||||
return ResponseChecksumValidation.valueOf(
|
||||
value.trim().toUpperCase(java.util.Locale.ROOT));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
log.warn(
|
||||
"Unknown storage.s3.response-checksum-validation value '{}', falling back to WHEN_SUPPORTED",
|
||||
value);
|
||||
return ResponseChecksumValidation.WHEN_SUPPORTED;
|
||||
}
|
||||
}
|
||||
}
|
||||
-226
@@ -1,226 +0,0 @@
|
||||
package stirling.software.proprietary.cluster.s3;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.FileStore;
|
||||
|
||||
import software.amazon.awssdk.core.ResponseInputStream;
|
||||
import software.amazon.awssdk.core.exception.SdkException;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
|
||||
/**
|
||||
* S3-backed {@link FileStore} for transient job-result files. Objects are namespaced under a
|
||||
* configurable key prefix (default {@code transient/}) and can coexist in the same bucket as {@code
|
||||
* S3StorageProvider}.
|
||||
*/
|
||||
@Slf4j
|
||||
public class S3FileStore implements FileStore, AutoCloseable {
|
||||
|
||||
public static final String DEFAULT_KEY_PREFIX = "transient/";
|
||||
|
||||
private final S3Client s3Client;
|
||||
private final String bucket;
|
||||
private final String keyPrefix;
|
||||
private final boolean ownsClient;
|
||||
|
||||
public S3FileStore(S3Client s3Client, String bucket) {
|
||||
this(s3Client, bucket, DEFAULT_KEY_PREFIX, true);
|
||||
}
|
||||
|
||||
public S3FileStore(S3Client s3Client, String bucket, String keyPrefix) {
|
||||
this(s3Client, bucket, keyPrefix, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ownsClient when true, {@link #close()} will close the supplied client. Set to false in
|
||||
* tests that share the client with another consumer.
|
||||
*/
|
||||
public S3FileStore(S3Client s3Client, String bucket, String keyPrefix, boolean ownsClient) {
|
||||
if (bucket == null || bucket.isBlank()) {
|
||||
throw new IllegalArgumentException("S3 bucket must be configured");
|
||||
}
|
||||
this.s3Client = s3Client;
|
||||
this.bucket = bucket;
|
||||
this.keyPrefix = normalizePrefix(keyPrefix);
|
||||
this.ownsClient = ownsClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stored store(InputStream in, String originalName) throws IOException {
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
// S3 PUT requires a known content-length; spool to a temp file first so memory stays
|
||||
// bounded for large payloads, then stream the file to S3 via RequestBody.fromFile.
|
||||
Path tempFile = Files.createTempFile("s3-upload-", ".bin");
|
||||
long size;
|
||||
try {
|
||||
try (InputStream src = in) {
|
||||
Files.copy(src, tempFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
size = Files.size(tempFile);
|
||||
PutObjectRequest request =
|
||||
PutObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build();
|
||||
try {
|
||||
s3Client.putObject(request, RequestBody.fromFile(tempFile));
|
||||
} catch (SdkException e) {
|
||||
throw new IOException("Failed to upload object to S3", e);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (IOException cleanupError) {
|
||||
log.warn("Failed to delete S3 upload temp file: {}", tempFile, cleanupError);
|
||||
}
|
||||
}
|
||||
return new Stored(fileId, size);
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream retrieve(String fileId) throws IOException {
|
||||
validateFileId(fileId);
|
||||
GetObjectRequest request =
|
||||
GetObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build();
|
||||
try {
|
||||
ResponseInputStream<GetObjectResponse> stream = s3Client.getObject(request);
|
||||
return new BufferedInputStream(stream);
|
||||
} catch (NoSuchKeyException e) {
|
||||
throw new IOException("File not found with ID: " + fileId, e);
|
||||
} catch (SdkException e) {
|
||||
throw new IOException("Failed to load object from S3", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] retrieveBytes(String fileId) throws IOException {
|
||||
validateFileId(fileId);
|
||||
GetObjectRequest request =
|
||||
GetObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build();
|
||||
try (ResponseInputStream<GetObjectResponse> stream = s3Client.getObject(request)) {
|
||||
return stream.readAllBytes();
|
||||
} catch (NoSuchKeyException e) {
|
||||
throw new IOException("File not found with ID: " + fileId, e);
|
||||
} catch (SdkException e) {
|
||||
throw new IOException("Failed to load object from S3", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long size(String fileId) throws IOException {
|
||||
validateFileId(fileId);
|
||||
HeadObjectRequest request =
|
||||
HeadObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build();
|
||||
try {
|
||||
HeadObjectResponse response = s3Client.headObject(request);
|
||||
return Optional.ofNullable(response.contentLength()).orElse(0L);
|
||||
} catch (NoSuchKeyException e) {
|
||||
throw new IOException("File not found with ID: " + fileId, e);
|
||||
} catch (S3Exception e) {
|
||||
if (e.statusCode() == 404) {
|
||||
throw new IOException("File not found with ID: " + fileId, e);
|
||||
}
|
||||
throw new IOException("Failed to head object in S3", e);
|
||||
} catch (SdkException e) {
|
||||
throw new IOException("Failed to head object in S3", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(String fileId) {
|
||||
try {
|
||||
validateFileId(fileId);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Refusing to delete invalid file id: {}", fileId);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
s3Client.deleteObject(
|
||||
DeleteObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build());
|
||||
return true;
|
||||
} catch (SdkException e) {
|
||||
log.error("Error deleting file with ID: {}", fileId, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String fileId) {
|
||||
try {
|
||||
validateFileId(fileId);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
HeadObjectRequest request =
|
||||
HeadObjectRequest.builder().bucket(bucket).key(resolveKey(fileId)).build();
|
||||
try {
|
||||
s3Client.headObject(request);
|
||||
return true;
|
||||
} catch (NoSuchKeyException e) {
|
||||
return false;
|
||||
} catch (S3Exception e) {
|
||||
if (e.statusCode() == 404) {
|
||||
return false;
|
||||
}
|
||||
log.warn("Error checking existence for file ID: {}", fileId, e);
|
||||
return false;
|
||||
} catch (SdkException e) {
|
||||
log.warn("Error checking existence for file ID: {}", fileId, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!ownsClient) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
s3Client.close();
|
||||
} catch (Exception e) {
|
||||
log.warn("Error closing S3 client", e);
|
||||
}
|
||||
}
|
||||
|
||||
String resolveKey(String fileId) {
|
||||
return keyPrefix + fileId;
|
||||
}
|
||||
|
||||
private static void validateFileId(String fileId) {
|
||||
if (fileId == null || fileId.isBlank()) {
|
||||
throw new IllegalArgumentException("File ID must not be blank");
|
||||
}
|
||||
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
|
||||
throw new IllegalArgumentException("Invalid file ID");
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizePrefix(String prefix) {
|
||||
if (prefix == null || prefix.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
String trimmed = prefix.trim();
|
||||
if (trimmed.startsWith("/")) {
|
||||
trimmed = trimmed.substring(1);
|
||||
}
|
||||
if (!trimmed.isEmpty() && !trimmed.endsWith("/")) {
|
||||
trimmed = trimmed + "/";
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
package stirling.software.proprietary.cluster.s3;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.FileStore;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/** Activates the S3-backed transient {@link FileStore} when {@code cluster.artifactStore=s3}. */
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(prefix = "cluster", name = "artifactStore", havingValue = "s3")
|
||||
public class S3FileStoreConfiguration {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
@ConditionalOnMissingBean
|
||||
public FileStore fileStore(@Value("${cluster.s3.keyPrefix:transient/}") String keyPrefix) {
|
||||
ApplicationProperties.Storage.S3 cfg = applicationProperties.getStorage().getS3();
|
||||
S3Clients.Bundle bundle = S3Clients.build(cfg, "cluster file store");
|
||||
// FileStore has no signed-URL contract; close the unused presigner immediately.
|
||||
try {
|
||||
bundle.presigner().close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
log.info("Cluster FileStore: s3 (bucket={}, keyPrefix={})", cfg.getBucket(), keyPrefix);
|
||||
return new S3FileStore(bundle.client(), cfg.getBucket(), keyPrefix, true);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package stirling.software.proprietary.cluster.valkey;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
|
||||
|
||||
/**
|
||||
* Composite condition: cluster mode is on AND the configured backplane is Valkey.
|
||||
*
|
||||
* <p>Either condition alone is insufficient to load a Valkey bean. With {@code enabled=true} but
|
||||
* {@code backplane=inprocess}, loading the Valkey beans would crash at boot because there's no
|
||||
* {@code StringRedisTemplate}; with {@code enabled=false} the whole cluster mode is off. Combining
|
||||
* the two stops both footguns.
|
||||
*
|
||||
* <p>Spring's {@code @ConditionalOnProperty} cannot be applied twice on the same class, so we use
|
||||
* {@code @ConditionalOnExpression} via this meta-annotation.
|
||||
*/
|
||||
@Target({ElementType.TYPE, ElementType.METHOD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@ConditionalOnExpression(
|
||||
"${cluster.enabled:false} and '${cluster.backplane:inprocess}'.equals('valkey')")
|
||||
public @interface ConditionalOnValkeyBackplane {}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package stirling.software.proprietary.cluster.valkey;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.data.redis.core.RedisCallback;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.ClusterBackplane;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
|
||||
@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(
|
||||
name = "cluster.backplane",
|
||||
havingValue = "valkey")
|
||||
public class ValkeyClusterBackplane implements ClusterBackplane {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final StringRedisTemplate template;
|
||||
|
||||
@Override
|
||||
public boolean isHealthy() {
|
||||
try {
|
||||
// template.execute() borrows from the pool and returns the connection in a finally
|
||||
// block - critical because isHealthy() is hit on every k8s liveness/readiness probe
|
||||
// tick. Calling getConnectionFactory().getConnection() directly leaks the connection
|
||||
// and exhausts the pool under monitoring load.
|
||||
String pong = template.execute((RedisCallback<String>) connection -> connection.ping());
|
||||
return "PONG".equalsIgnoreCase(pong);
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("Valkey backplane health check failed: {}", ex.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String backplaneType() {
|
||||
return "valkey";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String localNodeId() {
|
||||
return applicationProperties.getCluster().resolvedNodeId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the local {@code TaskManager#cleanupOldJobs} loop on Valkey-backed clusters: {@link
|
||||
* ValkeyJobStore} stores every entry with a TTL pExpire and the reverse-index entries share
|
||||
* that TTL, so Valkey itself evicts expired job state. Running the local cleanup loop on top of
|
||||
* that would only delete per-node in-memory {@code TaskManager} caches that the cluster-visible
|
||||
* {@code JobStore} has already authoritative state for.
|
||||
*/
|
||||
@Override
|
||||
public boolean shouldRunLocalCleanup() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
package stirling.software.proprietary.cluster.valkey;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
import org.springframework.data.redis.connection.RedisPassword;
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import io.lettuce.core.RedisCommandExecutionException;
|
||||
import io.lettuce.core.SslVerifyMode;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Cluster;
|
||||
|
||||
/** Wires the LettuceConnectionFactory and StringRedisTemplate for cluster mode. */
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true")
|
||||
@DependsOn("clusterLicenseGate")
|
||||
public class ValkeyConnectionConfiguration {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Bean(destroyMethod = "destroy")
|
||||
@ConditionalOnProperty(name = "cluster.backplane", havingValue = "valkey")
|
||||
public LettuceConnectionFactory valkeyConnectionFactory() {
|
||||
Cluster cluster = applicationProperties.getCluster();
|
||||
String url = cluster.getValkey().getUrl();
|
||||
if (url == null || url.isBlank()) {
|
||||
throw new IllegalStateException("cluster.valkey.url must be set when backplane=valkey");
|
||||
}
|
||||
URI uri = URI.create(url);
|
||||
boolean tls = "rediss".equalsIgnoreCase(uri.getScheme());
|
||||
int port = uri.getPort() <= 0 ? 6379 : uri.getPort();
|
||||
RedisStandaloneConfiguration cfg = new RedisStandaloneConfiguration(uri.getHost(), port);
|
||||
if (uri.getUserInfo() != null) {
|
||||
String[] parts = uri.getUserInfo().split(":", 2);
|
||||
if (parts.length == 2) {
|
||||
cfg.setUsername(parts[0]);
|
||||
cfg.setPassword(RedisPassword.of(parts[1]));
|
||||
} else if (parts.length == 1 && !parts[0].isBlank()) {
|
||||
cfg.setPassword(RedisPassword.of(parts[0]));
|
||||
}
|
||||
}
|
||||
boolean skipCertVerification =
|
||||
cluster.getValkey().getTls() != null
|
||||
&& cluster.getValkey().getTls().isSkipCertVerification();
|
||||
LettuceClientConfiguration clientConfig =
|
||||
buildClientConfiguration(tls, skipCertVerification);
|
||||
LettuceConnectionFactory factory = new LettuceConnectionFactory(cfg, clientConfig);
|
||||
factory.afterPropertiesSet();
|
||||
// Eager handshake with retry tolerates docker-compose DNS races; fails boot loudly
|
||||
// if Valkey is genuinely unreachable.
|
||||
eagerHandshake(factory, uri.getHost(), port, tls);
|
||||
log.info(
|
||||
"Valkey connection configured: {}:{} tls={} verifyPeer={}",
|
||||
uri.getHost(),
|
||||
port,
|
||||
tls,
|
||||
tls ? clientConfig.getVerifyMode() : "n/a");
|
||||
return factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Lettuce client configuration with TLS verification pinned. Package-private so unit
|
||||
* tests can verify the {@code verifyPeer} mode without standing up a real Valkey.
|
||||
*
|
||||
* <p>{@code verifyPeer(FULL)} is pinned explicitly so a future Spring Data Redis default change
|
||||
* cannot silently weaken our TLS handshake. {@code FULL} = X.509 chain + hostname check (per
|
||||
* Lettuce's {@link SslVerifyMode}). The {@code skipCertVerification} opt-out is for local dev
|
||||
* with self-signed certs only; production deployments MUST leave it false.
|
||||
*/
|
||||
static LettuceClientConfiguration buildClientConfiguration(
|
||||
boolean tls, boolean skipCertVerification) {
|
||||
LettuceClientConfiguration.LettuceClientConfigurationBuilder clientBuilder =
|
||||
LettuceClientConfiguration.builder();
|
||||
if (tls) {
|
||||
clientBuilder
|
||||
.useSsl()
|
||||
.verifyPeer(skipCertVerification ? SslVerifyMode.NONE : SslVerifyMode.FULL);
|
||||
if (skipCertVerification) {
|
||||
log.warn(
|
||||
"Valkey TLS hostname/chain verification DISABLED via"
|
||||
+ " cluster.valkey.tls.skip-cert-verification=true"
|
||||
+ " - insecure, dev-only");
|
||||
}
|
||||
}
|
||||
return clientBuilder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 10 x 3s = 30s of retry. Boot-time only.
|
||||
*
|
||||
* <p>Auth-class failures (WRONGPASS / NOAUTH / NOPERM) are unrecoverable and surfaced
|
||||
* immediately on the first attempt; only transport-level errors (connection refused, timeout,
|
||||
* host unreachable) get the retry loop.
|
||||
*
|
||||
* <p>Package-private so unit tests can drive it with a mocked connection factory.
|
||||
*/
|
||||
static void eagerHandshake(
|
||||
LettuceConnectionFactory factory, String host, int port, boolean tls) {
|
||||
RuntimeException last = null;
|
||||
for (int attempt = 1; attempt <= 10; attempt++) {
|
||||
try {
|
||||
String pong = factory.getConnection().ping();
|
||||
if (!"PONG".equalsIgnoreCase(pong)) {
|
||||
throw new IllegalStateException(
|
||||
"Valkey PING returned '" + pong + "' (expected PONG)");
|
||||
}
|
||||
if (attempt > 1) {
|
||||
log.info("Valkey reachable after {} attempts", attempt);
|
||||
}
|
||||
return;
|
||||
} catch (RuntimeException ex) {
|
||||
if (isAuthFailure(ex)) {
|
||||
factory.destroy();
|
||||
throw new IllegalStateException(
|
||||
"Valkey authentication failed for "
|
||||
+ host
|
||||
+ ":"
|
||||
+ port
|
||||
+ " (tls="
|
||||
+ tls
|
||||
+ "): "
|
||||
+ rootAuthMessage(ex)
|
||||
+ ". Check cluster.valkey.url credentials"
|
||||
+ " (user/password and ACL permissions).",
|
||||
ex);
|
||||
}
|
||||
last = ex;
|
||||
log.warn(
|
||||
"Valkey PING attempt {}/10 failed ({}:{}, tls={}): {}",
|
||||
attempt,
|
||||
host,
|
||||
port,
|
||||
tls,
|
||||
ex.getMessage());
|
||||
try {
|
||||
Thread.sleep(3000);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
factory.destroy();
|
||||
throw new IllegalStateException(
|
||||
"Valkey unreachable at boot after 10 attempts ("
|
||||
+ host
|
||||
+ ":"
|
||||
+ port
|
||||
+ ", tls="
|
||||
+ tls
|
||||
+ "): "
|
||||
+ (last == null ? "no detail" : last.getMessage()),
|
||||
last);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the cause chain for a Lettuce {@link RedisCommandExecutionException} whose message
|
||||
* starts with an auth-class server reply (WRONGPASS, NOAUTH, NOPERM). Spring Data Redis wraps
|
||||
* Lettuce errors in a {@code RedisSystemException}, so the auth signal usually lives one level
|
||||
* down from the thrown exception.
|
||||
*
|
||||
* <p>Checked for a typed alternative: neither Spring Data Redis 4.0.5 nor Lettuce 6.8.2 ships a
|
||||
* {@code RedisAuthenticationException} on the classpath, so we keep the message-prefix match.
|
||||
* Revisit when upgrading Spring Data Redis if a typed exception lands upstream.
|
||||
*/
|
||||
static boolean isAuthFailure(Throwable t) {
|
||||
for (Throwable cur = t; cur != null; cur = cur.getCause()) {
|
||||
if (cur instanceof RedisCommandExecutionException && hasAuthPrefix(cur.getMessage())) {
|
||||
return true;
|
||||
}
|
||||
// Defensive: some translations preserve the original message on the wrapper itself.
|
||||
if (hasAuthPrefix(cur.getMessage())) {
|
||||
return true;
|
||||
}
|
||||
if (cur.getCause() == cur) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasAuthPrefix(String message) {
|
||||
if (message == null) {
|
||||
return false;
|
||||
}
|
||||
String upper = message.toUpperCase(java.util.Locale.ROOT).stripLeading();
|
||||
return upper.startsWith("WRONGPASS")
|
||||
|| upper.startsWith("NOAUTH")
|
||||
|| upper.startsWith("NOPERM");
|
||||
}
|
||||
|
||||
private static String rootAuthMessage(Throwable t) {
|
||||
for (Throwable cur = t; cur != null; cur = cur.getCause()) {
|
||||
if (cur instanceof RedisCommandExecutionException && cur.getMessage() != null) {
|
||||
return cur.getMessage();
|
||||
}
|
||||
if (cur.getCause() == cur) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return t.getMessage();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "cluster.backplane", havingValue = "valkey")
|
||||
public StringRedisTemplate valkeyTemplate(LettuceConnectionFactory factory) {
|
||||
return new StringRedisTemplate(factory);
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package stirling.software.proprietary.cluster.valkey;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.data.redis.core.script.RedisScript;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.DistributedLock;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnValkeyBackplane
|
||||
@Slf4j
|
||||
public class ValkeyDistributedLock implements DistributedLock {
|
||||
|
||||
private static final String PREFIX = "stirling:lock:";
|
||||
|
||||
private static final RedisScript<Long> RELEASE_SCRIPT =
|
||||
new DefaultRedisScript<>(
|
||||
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
|
||||
Long.class);
|
||||
|
||||
private static final RedisScript<Long> RENEW_SCRIPT =
|
||||
new DefaultRedisScript<>(
|
||||
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end",
|
||||
Long.class);
|
||||
|
||||
private final StringRedisTemplate template;
|
||||
|
||||
@Override
|
||||
public Optional<LockHandle> tryAcquire(String lockKey, Duration leaseTime) {
|
||||
String key = PREFIX + lockKey;
|
||||
String value = UUID.randomUUID().toString();
|
||||
Boolean ok = template.opsForValue().setIfAbsent(key, value, leaseTime);
|
||||
if (Boolean.TRUE.equals(ok)) {
|
||||
return Optional.of(new ValkeyHandle(template, key, value));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static final class ValkeyHandle implements LockHandle {
|
||||
private final StringRedisTemplate template;
|
||||
private final String key;
|
||||
private final String value;
|
||||
private boolean released;
|
||||
|
||||
ValkeyHandle(StringRedisTemplate template, String key, String value) {
|
||||
this.template = template;
|
||||
this.key = key;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void release() {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
template.execute(RELEASE_SCRIPT, Collections.singletonList(key), value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized boolean renew(Duration leaseTime) {
|
||||
if (released) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Long result =
|
||||
template.execute(
|
||||
RENEW_SCRIPT,
|
||||
Collections.singletonList(key),
|
||||
value,
|
||||
Long.toString(leaseTime.toMillis()));
|
||||
return result != null && result == 1L;
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn(
|
||||
"Lock renew failed for {} (treated as lost lease): {}",
|
||||
key,
|
||||
ex.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package stirling.software.proprietary.cluster.valkey;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.redis.core.Cursor;
|
||||
import org.springframework.data.redis.core.RedisCallback;
|
||||
import org.springframework.data.redis.core.ScanOptions;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.cluster.ClusterNode;
|
||||
import stirling.software.common.cluster.InstanceRegistry;
|
||||
|
||||
/**
|
||||
* Valkey-backed {@link InstanceRegistry}. Each node is stored as a hash with a TTL equal to the
|
||||
* configured heartbeat TTL; the heartbeat re-arms the TTL.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnValkeyBackplane
|
||||
public class ValkeyInstanceRegistry implements InstanceRegistry {
|
||||
|
||||
private static final String PREFIX = "stirling:nodes:";
|
||||
|
||||
private final StringRedisTemplate template;
|
||||
|
||||
@Override
|
||||
public void register(ClusterNode node, Duration heartbeatTtl) {
|
||||
String key = PREFIX + node.nodeId();
|
||||
long ttlMs = heartbeatTtl.toMillis();
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
fields.put("nodeId", node.nodeId());
|
||||
fields.put("internalAddress", node.internalAddress());
|
||||
fields.put("role", node.role());
|
||||
fields.put("lastHeartbeat", node.lastHeartbeat().toString());
|
||||
|
||||
// MULTI/EXEC so the hash fields and the TTL commit together. Without this, a crash
|
||||
// between HSET and EXPIRE leaves the hash with no TTL: it never expires, masks the
|
||||
// dead node as alive, and only a subsequent successful register() would re-arm it.
|
||||
template.execute(
|
||||
(RedisCallback<Object>)
|
||||
connection -> {
|
||||
connection.multi();
|
||||
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
|
||||
Map<byte[], byte[]> hashBytes = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, String> f : fields.entrySet()) {
|
||||
hashBytes.put(
|
||||
f.getKey().getBytes(StandardCharsets.UTF_8),
|
||||
f.getValue().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
connection.hashCommands().hMSet(keyBytes, hashBytes);
|
||||
connection.keyCommands().pExpire(keyBytes, ttlMs);
|
||||
connection.exec();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ClusterNode> lookup(String nodeId) {
|
||||
return readNode(PREFIX + nodeId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<ClusterNode> activeNodes() {
|
||||
// SCAN, not KEYS - KEYS blocks the Valkey server for the duration of the walk.
|
||||
ScanOptions options = ScanOptions.scanOptions().match(PREFIX + "*").count(256).build();
|
||||
List<ClusterNode> nodes = new ArrayList<>();
|
||||
try (Cursor<String> cursor = template.scan(options)) {
|
||||
while (cursor.hasNext()) {
|
||||
readNode(cursor.next()).ifPresent(nodes::add);
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deregister(String nodeId) {
|
||||
template.delete(PREFIX + nodeId);
|
||||
}
|
||||
|
||||
private Optional<ClusterNode> readNode(String key) {
|
||||
Map<Object, Object> entries = template.opsForHash().entries(key);
|
||||
if (entries == null || entries.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Object nodeId = entries.get("nodeId");
|
||||
if (nodeId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Instant heartbeat = Instant.now();
|
||||
Object hb = entries.get("lastHeartbeat");
|
||||
if (hb != null) {
|
||||
try {
|
||||
heartbeat = Instant.parse(hb.toString());
|
||||
} catch (RuntimeException ignored) {
|
||||
// keep default
|
||||
}
|
||||
}
|
||||
return Optional.of(
|
||||
new ClusterNode(
|
||||
nodeId.toString(),
|
||||
String.valueOf(entries.getOrDefault("internalAddress", "")),
|
||||
heartbeat,
|
||||
String.valueOf(entries.getOrDefault("role", "BOTH"))));
|
||||
}
|
||||
}
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
package stirling.software.proprietary.cluster.valkey;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.redis.core.Cursor;
|
||||
import org.springframework.data.redis.core.RedisCallback;
|
||||
import org.springframework.data.redis.core.ScanOptions;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.JobStore;
|
||||
import stirling.software.common.cluster.JobStoreEntry;
|
||||
|
||||
/**
|
||||
* Valkey-backed {@link JobStore}. Each job is one hash; a reverse index maps fileId to jobId.
|
||||
*
|
||||
* <p><b>put() atomicity:</b> the hash fields, the per-job TTL, and the reverse-index entries are
|
||||
* issued inside a single pipelined Redis transaction (MULTI/EXEC). A partial failure cannot leave
|
||||
* the hash without a TTL or with half the file→job index entries written.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnValkeyBackplane
|
||||
@Slf4j
|
||||
public class ValkeyJobStore implements JobStore {
|
||||
|
||||
private static final String JOB_PREFIX = "stirling:job:";
|
||||
private static final String FILE_INDEX_PREFIX = "stirling:file2job:";
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final TypeReference<List<String>> LIST_STRING = new TypeReference<>() {};
|
||||
private static final TypeReference<Map<String, String>> MAP_STRING = new TypeReference<>() {};
|
||||
|
||||
private final StringRedisTemplate template;
|
||||
|
||||
@Override
|
||||
public void put(JobStoreEntry entry, Duration ttl) {
|
||||
String key = JOB_PREFIX + entry.jobId();
|
||||
long ttlMs = ttl.toMillis();
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
fields.put("jobId", entry.jobId());
|
||||
fields.put("state", entry.state().name());
|
||||
fields.put("owningNodeId", entry.owningNodeId() == null ? "" : entry.owningNodeId());
|
||||
if (entry.createdAt() != null) {
|
||||
fields.put("createdAt", entry.createdAt().toString());
|
||||
}
|
||||
if (entry.completedAt() != null) {
|
||||
fields.put("completedAt", entry.completedAt().toString());
|
||||
}
|
||||
if (entry.error() != null) {
|
||||
fields.put("error", entry.error());
|
||||
}
|
||||
fields.put("fileIds", writeJson(entry.fileIds() == null ? List.of() : entry.fileIds()));
|
||||
fields.put(
|
||||
"resultMeta",
|
||||
writeJson(entry.resultMeta() == null ? Map.of() : entry.resultMeta()));
|
||||
|
||||
// Build pipelined MULTI/EXEC so the hash, its TTL, and every reverse-index entry
|
||||
// commit atomically.
|
||||
template.execute(
|
||||
(RedisCallback<Object>)
|
||||
connection -> {
|
||||
connection.multi();
|
||||
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
|
||||
Map<byte[], byte[]> hashBytes = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, String> f : fields.entrySet()) {
|
||||
hashBytes.put(
|
||||
f.getKey().getBytes(StandardCharsets.UTF_8),
|
||||
f.getValue().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
connection.hashCommands().hMSet(keyBytes, hashBytes);
|
||||
connection.keyCommands().pExpire(keyBytes, ttlMs);
|
||||
if (entry.fileIds() != null) {
|
||||
for (String fileId : entry.fileIds()) {
|
||||
byte[] idxKey =
|
||||
(FILE_INDEX_PREFIX + fileId)
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
connection
|
||||
.stringCommands()
|
||||
.set(
|
||||
idxKey,
|
||||
entry.jobId().getBytes(StandardCharsets.UTF_8));
|
||||
connection.keyCommands().pExpire(idxKey, ttlMs);
|
||||
}
|
||||
}
|
||||
connection.exec();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<JobStoreEntry> get(String jobId) {
|
||||
return readEntry(JOB_PREFIX + jobId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String jobId) {
|
||||
// WATCH/MULTI/EXEC: read fileIds INSIDE the watched scope so a concurrent put() that
|
||||
// adds new fileIds between our read and EXEC aborts the transaction. Without this guard,
|
||||
// an interleaved put() that grows fileIds would leave orphaned reverse-index entries
|
||||
// pointing at the deleted jobId until their TTL expires. One retry handles the common
|
||||
// case; further contention falls through to lazy TTL cleanup (acceptable - this is an
|
||||
// eviction path, not a correctness primitive).
|
||||
String jobKey = JOB_PREFIX + jobId;
|
||||
byte[] jobKeyBytes = jobKey.getBytes(StandardCharsets.UTF_8);
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
Boolean committed =
|
||||
template.execute(
|
||||
(RedisCallback<Boolean>)
|
||||
connection -> {
|
||||
connection.watch(jobKeyBytes);
|
||||
Map<byte[], byte[]> hash =
|
||||
connection.hashCommands().hGetAll(jobKeyBytes);
|
||||
List<byte[]> keysToDelete = new ArrayList<>();
|
||||
keysToDelete.add(jobKeyBytes);
|
||||
if (hash != null) {
|
||||
byte[] fileIdsBytes =
|
||||
hash.get(
|
||||
"fileIds"
|
||||
.getBytes(
|
||||
StandardCharsets
|
||||
.UTF_8));
|
||||
if (fileIdsBytes != null) {
|
||||
List<String> fileIds =
|
||||
readJsonList(
|
||||
new String(
|
||||
fileIdsBytes,
|
||||
StandardCharsets.UTF_8),
|
||||
jobKey);
|
||||
for (String fileId : fileIds) {
|
||||
keysToDelete.add(
|
||||
(FILE_INDEX_PREFIX + fileId)
|
||||
.getBytes(
|
||||
StandardCharsets
|
||||
.UTF_8));
|
||||
}
|
||||
}
|
||||
}
|
||||
connection.multi();
|
||||
for (byte[] key : keysToDelete) {
|
||||
connection.keyCommands().del(key);
|
||||
}
|
||||
List<Object> results = connection.exec();
|
||||
// exec() returns null when WATCH detected a concurrent
|
||||
// write; spring-data-redis surfaces this as either null
|
||||
// or empty depending on the driver path.
|
||||
return results != null && !results.isEmpty();
|
||||
});
|
||||
if (Boolean.TRUE.equals(committed)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
log.warn(
|
||||
"JobStore.delete({}) lost two WATCH races to concurrent put(); reverse-index"
|
||||
+ " entries may linger until TTL expiry",
|
||||
jobId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String jobId) {
|
||||
Boolean exists = template.hasKey(JOB_PREFIX + jobId);
|
||||
return Boolean.TRUE.equals(exists);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> findJobIdByFileId(String fileId) {
|
||||
return Optional.ofNullable(template.opsForValue().get(FILE_INDEX_PREFIX + fileId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<JobStoreEntry> all() {
|
||||
// SCAN, not KEYS - KEYS blocks the Valkey server for the duration of the walk.
|
||||
ScanOptions options = ScanOptions.scanOptions().match(JOB_PREFIX + "*").count(256).build();
|
||||
List<JobStoreEntry> result = new ArrayList<>();
|
||||
try (Cursor<String> cursor = template.scan(options)) {
|
||||
while (cursor.hasNext()) {
|
||||
readEntry(cursor.next()).ifPresent(result::add);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Optional<JobStoreEntry> readEntry(String key) {
|
||||
Map<Object, Object> entries = template.opsForHash().entries(key);
|
||||
if (entries == null || entries.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Object jobId = entries.get("jobId");
|
||||
if (jobId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Instant createdAt = parseInstant(entries.get("createdAt"), key, "createdAt");
|
||||
Instant completedAt = parseInstant(entries.get("completedAt"), key, "completedAt");
|
||||
List<String> fileIds = parseList(entries.get("fileIds"), key);
|
||||
Map<String, String> resultMeta = parseMap(entries.get("resultMeta"), key);
|
||||
String stateName =
|
||||
String.valueOf(
|
||||
entries.getOrDefault("state", JobStoreEntry.JobState.PENDING.name()));
|
||||
JobStoreEntry.JobState state;
|
||||
try {
|
||||
state = JobStoreEntry.JobState.valueOf(stateName);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
log.warn("Unrecognised job state '{}' in {}, defaulting to PENDING", stateName, key);
|
||||
state = JobStoreEntry.JobState.PENDING;
|
||||
}
|
||||
String owningNodeId = String.valueOf(entries.getOrDefault("owningNodeId", ""));
|
||||
String error = entries.get("error") == null ? null : entries.get("error").toString();
|
||||
return Optional.of(
|
||||
new JobStoreEntry(
|
||||
jobId.toString(),
|
||||
state,
|
||||
owningNodeId,
|
||||
createdAt,
|
||||
completedAt,
|
||||
error,
|
||||
fileIds,
|
||||
resultMeta));
|
||||
}
|
||||
|
||||
private Instant parseInstant(Object v, String key, String field) {
|
||||
if (v == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Instant.parse(v.toString());
|
||||
} catch (RuntimeException e) {
|
||||
log.warn(
|
||||
"JobStore {} field '{}' has malformed timestamp '{}' - treating as missing",
|
||||
key,
|
||||
field,
|
||||
v);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> parseList(Object v, String key) {
|
||||
if (v == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return readJsonList(v.toString(), key);
|
||||
}
|
||||
|
||||
private Map<String, String> parseMap(Object v, String key) {
|
||||
if (v == null) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
try {
|
||||
return MAPPER.readValue(v.toString(), MAP_STRING);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn(
|
||||
"JobStore {} field 'resultMeta' is not valid JSON '{}' - treating as empty",
|
||||
key,
|
||||
v);
|
||||
return new HashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
private static String writeJson(Object value) {
|
||||
try {
|
||||
return MAPPER.writeValueAsString(value);
|
||||
} catch (JsonProcessingException e) {
|
||||
// The shapes we serialize are simple List<String> / Map<String,String>; Jackson
|
||||
// can encode these without escapes that fail. Surface anything unexpected loud and
|
||||
// early rather than persisting a half-serialized field that would re-throw on read.
|
||||
throw new IllegalStateException("Failed to JSON-serialize JobStore field", e);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> readJsonList(String json, String key) {
|
||||
try {
|
||||
List<String> parsed = MAPPER.readValue(json, LIST_STRING);
|
||||
return parsed == null ? new ArrayList<>() : parsed;
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn(
|
||||
"JobStore {} field 'fileIds' is not valid JSON '{}' - treating as empty",
|
||||
key,
|
||||
json);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package stirling.software.proprietary.cluster.valkey;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.data.redis.core.Cursor;
|
||||
import org.springframework.data.redis.core.ScanOptions;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.cluster.KeyValueCache;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnValkeyBackplane
|
||||
public class ValkeyKeyValueCache implements KeyValueCache {
|
||||
|
||||
private static final String PREFIX = "stirling:kv:";
|
||||
|
||||
private final StringRedisTemplate template;
|
||||
|
||||
@Override
|
||||
public void put(String namespace, String key, String value, Duration ttl) {
|
||||
template.opsForValue()
|
||||
.set(buildKey(namespace, key), value, ttl.toMillis(), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> get(String namespace, String key) {
|
||||
return Optional.ofNullable(template.opsForValue().get(buildKey(namespace, key)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void evict(String namespace, String key) {
|
||||
template.delete(buildKey(namespace, key));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void evictNamespace(String namespace) {
|
||||
// SCAN, not KEYS: KEYS blocks the server until it has walked the entire keyspace.
|
||||
ScanOptions options =
|
||||
ScanOptions.scanOptions().match(PREFIX + namespace + ":*").count(256).build();
|
||||
List<String> keys = new ArrayList<>();
|
||||
try (Cursor<String> cursor = template.scan(options)) {
|
||||
while (cursor.hasNext()) {
|
||||
keys.add(cursor.next());
|
||||
}
|
||||
}
|
||||
if (!keys.isEmpty()) {
|
||||
template.delete(keys);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildKey(String namespace, String key) {
|
||||
return PREFIX + namespace + ":" + key;
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package stirling.software.proprietary.cluster.valkey;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import io.github.bucket4j.BucketConfiguration;
|
||||
import io.github.bucket4j.ConsumptionProbe;
|
||||
import io.github.bucket4j.distributed.BucketProxy;
|
||||
import io.github.bucket4j.distributed.proxy.ProxyManager;
|
||||
import io.github.bucket4j.redis.lettuce.Bucket4jLettuce;
|
||||
import io.lettuce.core.AbstractRedisClient;
|
||||
import io.lettuce.core.RedisClient;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import stirling.software.common.cluster.RateLimitStore;
|
||||
|
||||
/**
|
||||
* Valkey-backed token-bucket rate limiting via Bucket4j's Lettuce ProxyManager.
|
||||
*
|
||||
* <p>Replaces the earlier hand-rolled INCR+EXPIRE Lua fixed-window script. The fixed-window impl
|
||||
* could allow a caller to spend the full bucket at second 59 of one window and the full bucket
|
||||
* again at second 1 of the next window (effective burst of 2x capacity at boundaries). The Bucket4j
|
||||
* token bucket refills continuously and removes that boundary doubling, giving cross-node parity
|
||||
* with the in-process {@code InProcessRateLimitStore} which already uses Bucket4j.
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnValkeyBackplane
|
||||
public class ValkeyRateLimitStore implements RateLimitStore {
|
||||
|
||||
private static final String PREFIX = "stirling:rl:";
|
||||
|
||||
private final LettuceConnectionFactory connectionFactory;
|
||||
private ProxyManager<byte[]> proxyManager;
|
||||
|
||||
public ValkeyRateLimitStore(LettuceConnectionFactory connectionFactory) {
|
||||
this.connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void initProxyManager() {
|
||||
AbstractRedisClient client = connectionFactory.getNativeClient();
|
||||
if (!(client instanceof RedisClient redisClient)) {
|
||||
throw new IllegalStateException(
|
||||
"ValkeyRateLimitStore requires a standalone Lettuce RedisClient; got "
|
||||
+ (client == null ? "null" : client.getClass().getName())
|
||||
+ " (cluster client not yet supported by this rate limit impl)");
|
||||
}
|
||||
this.proxyManager = Bucket4jLettuce.casBasedBuilder(redisClient).build();
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void shutdown() {
|
||||
// Lettuce client lifecycle is owned by Spring (LettuceConnectionFactory#destroy), so we
|
||||
// only drop the proxy reference. No explicit close needed.
|
||||
proxyManager = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RateLimitDecision tryConsume(String bucketKey, long capacity, Duration refillPeriod) {
|
||||
byte[] key = (PREFIX + bucketKey).getBytes(StandardCharsets.UTF_8);
|
||||
// Greedy refill of capacity tokens per refillPeriod, matching InProcessRateLimitStore
|
||||
// semantics (continuously refilling, no fixed-window boundary doubling).
|
||||
BucketConfiguration cfg =
|
||||
BucketConfiguration.builder()
|
||||
.addLimit(
|
||||
stage ->
|
||||
stage.capacity(capacity)
|
||||
.refillGreedy(capacity, refillPeriod))
|
||||
.build();
|
||||
BucketProxy bucket = proxyManager.builder().build(key, () -> cfg);
|
||||
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
|
||||
if (probe.isConsumed()) {
|
||||
return new RateLimitDecision(true, probe.getRemainingTokens(), 0L);
|
||||
}
|
||||
return new RateLimitDecision(false, 0L, probe.getNanosToWaitForRefill());
|
||||
}
|
||||
}
|
||||
+1
-5
@@ -49,11 +49,7 @@ public class EEAppConfig {
|
||||
@Profile("security & !saas")
|
||||
@Bean(name = "SSOAutoLogin")
|
||||
public boolean ssoAutoLogin() {
|
||||
boolean enabled = applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
|
||||
if (enabled) {
|
||||
licenseKeyChecker.requireProOrEnterprise("premium.proFeatures.ssoAutoLogin=true");
|
||||
}
|
||||
return enabled;
|
||||
return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
|
||||
}
|
||||
|
||||
// TODO: Remove post migration
|
||||
|
||||
+1
-16
@@ -32,10 +32,7 @@ public class LicenseKeyChecker {
|
||||
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
|
||||
// volatile: written by evaluateLicense() on the @Scheduled refresh thread, read by request
|
||||
// threads via getPremiumLicenseEnabledResult() / requireProOrEnterprise(). Ensures readers see
|
||||
// the latest tier rather than a stale cached value.
|
||||
private volatile License premiumEnabledResult = License.NORMAL;
|
||||
private License premiumEnabledResult = License.NORMAL;
|
||||
|
||||
public LicenseKeyChecker(
|
||||
KeygenLicenseVerifier licenseService,
|
||||
@@ -136,16 +133,4 @@ public class LicenseKeyChecker {
|
||||
public License getPremiumLicenseEnabledResult() {
|
||||
return premiumEnabledResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws {@link IllegalStateException} if the current license is not Pro or Enterprise. Used by
|
||||
* boot-time gates to fail fast when an operator enables a premium-only setting without a valid
|
||||
* license. {@code configuredAs} is the human-readable property path (e.g. {@code
|
||||
* "storage.provider=s3"}) and appears in the exception message.
|
||||
*/
|
||||
public void requireProOrEnterprise(String configuredAs) {
|
||||
if (premiumEnabledResult != License.SERVER && premiumEnabledResult != License.ENTERPRISE) {
|
||||
throw new IllegalStateException(configuredAs + " requires a Pro or Enterprise license");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
-54
@@ -2,9 +2,9 @@ package stirling.software.proprietary.security.filter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.apache.commons.codec.digest.DigestUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -15,9 +15,6 @@ import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket;
|
||||
import io.github.bucket4j.ConsumptionProbe;
|
||||
import io.github.pixee.security.Newlines;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
@@ -25,22 +22,28 @@ import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import stirling.software.common.cluster.RateLimitStore;
|
||||
import stirling.software.common.cluster.RateLimitStore.RateLimitDecision;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.proprietary.cluster.ClusterMetrics;
|
||||
|
||||
@Component
|
||||
@Profile("!saas")
|
||||
public class UserBasedRateLimitingFilter extends OncePerRequestFilter {
|
||||
|
||||
private final Map<String, Bucket> apiBuckets = new ConcurrentHashMap<>();
|
||||
|
||||
private final Map<String, Bucket> webBuckets = new ConcurrentHashMap<>();
|
||||
private final RateLimitStore rateLimitStore;
|
||||
|
||||
@Qualifier("rateLimit")
|
||||
private final boolean rateLimit;
|
||||
|
||||
public UserBasedRateLimitingFilter(@Qualifier("rateLimit") boolean rateLimit) {
|
||||
@Autowired(required = false)
|
||||
private ClusterMetrics clusterMetrics;
|
||||
|
||||
public UserBasedRateLimitingFilter(
|
||||
@Qualifier("rateLimit") boolean rateLimit, RateLimitStore rateLimitStore) {
|
||||
this.rateLimit = rateLimit;
|
||||
this.rateLimitStore = rateLimitStore;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -48,54 +51,44 @@ public class UserBasedRateLimitingFilter extends OncePerRequestFilter {
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
if (!rateLimit) {
|
||||
// If rateLimit is not enabled, just pass all requests without rate limiting
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
String method = request.getMethod();
|
||||
if (!"POST".equalsIgnoreCase(method)) {
|
||||
// If the request is not a POST, just pass it through without rate limiting
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
String identifier = null;
|
||||
// Check for API key in the request headers
|
||||
String identifier;
|
||||
String apiKey = request.getHeader("X-API-KEY");
|
||||
if (apiKey != null && !apiKey.trim().isEmpty()) {
|
||||
identifier = // Prefix to distinguish between API keys and usernames
|
||||
"API_KEY_" + apiKey;
|
||||
// Hash the API key so the raw value never appears in any Valkey rate-limit bucket key.
|
||||
identifier = "API_KEY_" + DigestUtils.sha256Hex(apiKey);
|
||||
} else {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && authentication.isAuthenticated()) {
|
||||
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
|
||||
// AnonymousAuthenticationToken.isAuthenticated() == true but principal is
|
||||
// "anonymousUser";
|
||||
// guard the cast so anonymous requests fall through to the remote-addr branch.
|
||||
if (authentication != null
|
||||
&& authentication.isAuthenticated()
|
||||
&& authentication.getPrincipal() instanceof UserDetails userDetails) {
|
||||
identifier = userDetails.getUsername();
|
||||
} else {
|
||||
identifier = request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
// If neither API key nor an authenticated user is present, use IP address
|
||||
if (identifier == null) {
|
||||
identifier = request.getRemoteAddr();
|
||||
}
|
||||
Role userRole =
|
||||
getRoleFromAuthentication(SecurityContextHolder.getContext().getAuthentication());
|
||||
String scope;
|
||||
int limitPerDay;
|
||||
if (request.getHeader("X-API-KEY") != null) {
|
||||
// It's an API call
|
||||
processRequest(
|
||||
userRole.getApiCallsPerDay(),
|
||||
identifier,
|
||||
apiBuckets,
|
||||
request,
|
||||
response,
|
||||
filterChain);
|
||||
scope = "api:";
|
||||
limitPerDay = userRole.getApiCallsPerDay();
|
||||
} else {
|
||||
// It's a Web UI call
|
||||
processRequest(
|
||||
userRole.getWebCallsPerDay(),
|
||||
identifier,
|
||||
webBuckets,
|
||||
request,
|
||||
response,
|
||||
filterChain);
|
||||
scope = "web:";
|
||||
limitPerDay = userRole.getWebCallsPerDay();
|
||||
}
|
||||
processRequest(limitPerDay, scope + identifier, request, response, filterChain);
|
||||
}
|
||||
|
||||
private Role getRoleFromAuthentication(Authentication authentication) {
|
||||
@@ -108,43 +101,50 @@ public class UserBasedRateLimitingFilter extends OncePerRequestFilter {
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("User does not have a valid role.");
|
||||
return Role.WEB_ONLY_USER; // no matching authority - use most restrictive bucket
|
||||
}
|
||||
|
||||
private void processRequest(
|
||||
int limitPerDay,
|
||||
String identifier,
|
||||
Map<String, Bucket> buckets,
|
||||
String bucketKey,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain)
|
||||
throws IOException, ServletException {
|
||||
Bucket userBucket = buckets.computeIfAbsent(identifier, k -> createUserBucket(limitPerDay));
|
||||
ConsumptionProbe probe = userBucket.tryConsumeAndReturnRemaining(1);
|
||||
if (probe.isConsumed()) {
|
||||
RateLimitDecision probe;
|
||||
try {
|
||||
probe = rateLimitStore.tryConsume(bucketKey, limitPerDay, Duration.ofDays(1));
|
||||
} catch (RuntimeException ex) {
|
||||
// Fail OPEN: a rate-limit backend outage (e.g. Valkey unreachable in cluster mode)
|
||||
// must not turn every POST into a 500. Availability beats strict enforcement here -
|
||||
// allow the request through and log so the outage stays visible. The in-process store
|
||||
// never throws, so single-node behaviour is unchanged.
|
||||
logger.warn(
|
||||
"Rate-limit backend unavailable for "
|
||||
+ bucketKey
|
||||
+ "; allowing request (fail-open): "
|
||||
+ ex.getMessage());
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
if (probe.allowed()) {
|
||||
response.setHeader(
|
||||
"X-Rate-Limit-Remaining",
|
||||
stripNewlines(Newlines.stripAll(Long.toString(probe.getRemainingTokens()))));
|
||||
stripNewlines(Newlines.stripAll(Long.toString(probe.remainingTokens()))));
|
||||
filterChain.doFilter(request, response);
|
||||
} else {
|
||||
long waitForRefill = probe.getNanosToWaitForRefill() / 1_000_000_000;
|
||||
long waitForRefill = probe.nanosToWaitForRefill() / 1_000_000_000;
|
||||
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
||||
response.setHeader(
|
||||
"X-Rate-Limit-Retry-After-Seconds",
|
||||
Newlines.stripAll(String.valueOf(waitForRefill)));
|
||||
response.getWriter().write("Rate limit exceeded for POST requests.");
|
||||
if (clusterMetrics != null) {
|
||||
clusterMetrics.recordRateLimitReject();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Bucket createUserBucket(int limitPerDay) {
|
||||
Bandwidth limit =
|
||||
Bandwidth.builder()
|
||||
.capacity(limitPerDay)
|
||||
.refillIntervally(limitPerDay, Duration.ofDays(1))
|
||||
.build();
|
||||
return Bucket.builder().addLimit(limit).build();
|
||||
}
|
||||
|
||||
private static String stripNewlines(final String s) {
|
||||
return RegexPatternUtils.getInstance().getNewlineCharsPattern().matcher(s).replaceAll("");
|
||||
}
|
||||
|
||||
+4
-171
@@ -1,10 +1,6 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import org.springframework.security.authentication.LockedException;
|
||||
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
|
||||
@@ -12,8 +8,6 @@ import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
|
||||
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
|
||||
@@ -45,37 +39,20 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
|
||||
|
||||
@Override
|
||||
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
|
||||
String registrationId = userRequest.getClientRegistration().getRegistrationId();
|
||||
boolean debugLogging = Boolean.TRUE.equals(oauth2Properties.getDebugLogging());
|
||||
// Resolved inside the try so a bad/null useAsUsername (IllegalArgumentException from
|
||||
// valueOf, or NPE on toUpperCase) is caught and wrapped as OAuth2AuthenticationException
|
||||
// by the existing handlers below, matching the pre-debugLogging behaviour.
|
||||
String usernameAttributeKey = null;
|
||||
|
||||
try {
|
||||
usernameAttributeKey =
|
||||
OidcUser user = delegate.loadUser(userRequest);
|
||||
String usernameAttributeKey =
|
||||
UsernameAttribute.valueOf(oauth2Properties.getUseAsUsername().toUpperCase())
|
||||
.getName();
|
||||
OidcUser user = delegate.loadUser(userRequest);
|
||||
|
||||
if (debugLogging) {
|
||||
logClaimDump(
|
||||
"OAuth2/OIDC login claims received",
|
||||
registrationId,
|
||||
usernameAttributeKey,
|
||||
user.getIdToken(),
|
||||
user.getUserInfo(),
|
||||
user.getAttributes(),
|
||||
false);
|
||||
}
|
||||
|
||||
// Extract SSO provider information
|
||||
String ssoProviderId = user.getSubject(); // Standard OIDC 'sub' claim
|
||||
String ssoProvider = userRequest.getClientRegistration().getRegistrationId();
|
||||
String username = user.getAttribute(usernameAttributeKey);
|
||||
|
||||
log.debug(
|
||||
"OAuth2 login - Provider: {}, ProviderId: {}, Username: {}",
|
||||
registrationId,
|
||||
ssoProvider,
|
||||
ssoProviderId,
|
||||
username);
|
||||
|
||||
@@ -102,154 +79,10 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
|
||||
usernameAttributeKey);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.error("Error loading OIDC user: {}", e.getMessage());
|
||||
// Only emit the claim dump if we successfully resolved usernameAttributeKey. A null
|
||||
// value here means UsernameAttribute.valueOf rejected the configured useAsUsername
|
||||
// before delegate.loadUser ran — that error message is self-explanatory and a claim
|
||||
// dump would have no resolved-key to compare against.
|
||||
if (debugLogging && usernameAttributeKey != null) {
|
||||
// The DefaultOidcUser constructor (or our own checks) rejected the chosen
|
||||
// username attribute. Dump the claims we DID receive so the operator can pick
|
||||
// a different value for security.oauth2.useAsUsername.
|
||||
logClaimDump(
|
||||
"OAuth2/OIDC login FAILED - dumping received claims",
|
||||
registrationId,
|
||||
usernameAttributeKey,
|
||||
userRequest.getIdToken(),
|
||||
null,
|
||||
userRequest.getIdToken() == null
|
||||
? Collections.emptyMap()
|
||||
: userRequest.getIdToken().getClaims(),
|
||||
true);
|
||||
}
|
||||
throw new OAuth2AuthenticationException(new OAuth2Error(e.getMessage()), e);
|
||||
} catch (Exception e) {
|
||||
log.error("Unexpected error loading OIDC user", e);
|
||||
if (debugLogging && usernameAttributeKey != null && userRequest.getIdToken() != null) {
|
||||
logClaimDump(
|
||||
"OAuth2/OIDC login FAILED (unexpected error) - dumping ID token claims",
|
||||
registrationId,
|
||||
usernameAttributeKey,
|
||||
userRequest.getIdToken(),
|
||||
null,
|
||||
userRequest.getIdToken().getClaims(),
|
||||
true);
|
||||
}
|
||||
throw new OAuth2AuthenticationException("Unexpected error during authentication");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a multi-line diagnostic dump of the claims returned by the OAuth2/OIDC provider. Only
|
||||
* invoked when {@code security.oauth2.debugLogging=true}.
|
||||
*
|
||||
* @param banner short title for the log block
|
||||
* @param registrationId Spring client registration id (e.g. "demarest", "keycloak")
|
||||
* @param usernameAttributeKey the claim key the application is configured to use as username
|
||||
* @param idToken the decoded ID token, may be null on unexpected failures
|
||||
* @param userInfo the decoded UserInfo response, may be null if the provider returned none
|
||||
* @param mergedAttributes the merged attribute map Spring uses for {@code getAttribute()}
|
||||
* @param failure true if logging in the error path (uses ERROR level), false for INFO
|
||||
*/
|
||||
private void logClaimDump(
|
||||
String banner,
|
||||
String registrationId,
|
||||
String usernameAttributeKey,
|
||||
OidcIdToken idToken,
|
||||
OidcUserInfo userInfo,
|
||||
Map<String, Object> mergedAttributes,
|
||||
boolean failure) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("\n========== [OAUTH2 DEBUG] ").append(banner).append(" ==========\n");
|
||||
sb.append("Provider registrationId : ").append(registrationId).append('\n');
|
||||
sb.append("Configured useAsUsername: ")
|
||||
.append(oauth2Properties.getUseAsUsername())
|
||||
.append(" (looks up claim key '")
|
||||
.append(usernameAttributeKey)
|
||||
.append("')\n");
|
||||
|
||||
if (idToken != null) {
|
||||
Map<String, Object> idClaims = idToken.getClaims();
|
||||
sb.append("\n-- ID token claims (")
|
||||
.append(idClaims == null ? 0 : idClaims.size())
|
||||
.append(") --\n");
|
||||
appendClaims(sb, idClaims);
|
||||
sb.append("ID token issued at : ").append(idToken.getIssuedAt()).append('\n');
|
||||
sb.append("ID token expires at: ").append(idToken.getExpiresAt()).append('\n');
|
||||
} else {
|
||||
sb.append("\n-- ID token: <null> --\n");
|
||||
}
|
||||
|
||||
if (userInfo != null && userInfo.getClaims() != null) {
|
||||
sb.append("\n-- UserInfo endpoint claims (")
|
||||
.append(userInfo.getClaims().size())
|
||||
.append(") --\n");
|
||||
appendClaims(sb, userInfo.getClaims());
|
||||
} else {
|
||||
sb.append("\n-- UserInfo endpoint claims: none returned --\n");
|
||||
}
|
||||
|
||||
if (mergedAttributes != null) {
|
||||
sb.append("\n-- Merged attribute keys available to useAsUsername: ")
|
||||
.append(new TreeSet<>(mergedAttributes.keySet()))
|
||||
.append("\n");
|
||||
Object resolved = mergedAttributes.get(usernameAttributeKey);
|
||||
sb.append("-- Value at '")
|
||||
.append(usernameAttributeKey)
|
||||
.append("' : ")
|
||||
.append(resolved == null ? "<NULL — this is why login fails>" : resolved)
|
||||
.append('\n');
|
||||
|
||||
if (resolved == null) {
|
||||
Set<String> hints = suggestUsernameClaims(mergedAttributes.keySet());
|
||||
if (!hints.isEmpty()) {
|
||||
sb.append(
|
||||
"-- Hint: the following claim(s) are present and map to a"
|
||||
+ " known UsernameAttribute value — try setting"
|
||||
+ " security.oauth2.useAsUsername to one of: ")
|
||||
.append(hints)
|
||||
.append('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sb.append(
|
||||
"\nWARNING: this block contains PII. Set security.oauth2.debugLogging=false once"
|
||||
+ " troubleshooting is complete.\n");
|
||||
sb.append("========== [/OAUTH2 DEBUG] ==========");
|
||||
|
||||
if (failure) {
|
||||
log.error(sb.toString());
|
||||
} else {
|
||||
log.info(sb.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendClaims(StringBuilder sb, Map<String, Object> claims) {
|
||||
if (claims == null || claims.isEmpty()) {
|
||||
sb.append(" (no claims)\n");
|
||||
return;
|
||||
}
|
||||
// Sort for stable, scannable output
|
||||
new TreeSet<>(claims.keySet())
|
||||
.forEach(
|
||||
key -> {
|
||||
Object value = claims.get(key);
|
||||
sb.append(" ").append(key).append(" = ").append(value).append('\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the intersection of the claim keys the provider actually returned and the keys that
|
||||
* {@link UsernameAttribute} accepts — i.e. valid values the operator could put in {@code
|
||||
* security.oauth2.useAsUsername} to make this login work.
|
||||
*/
|
||||
private static Set<String> suggestUsernameClaims(Set<String> availableClaimKeys) {
|
||||
Set<String> supported = new TreeSet<>();
|
||||
for (UsernameAttribute attr : UsernameAttribute.values()) {
|
||||
if (availableClaimKeys.contains(attr.getName())) {
|
||||
supported.add(attr.getName());
|
||||
}
|
||||
}
|
||||
return supported;
|
||||
}
|
||||
}
|
||||
|
||||
+27
-27
@@ -210,20 +210,37 @@ public class JwtService implements JwtServiceInterface {
|
||||
if (specificKeyPair.isPresent()) {
|
||||
keyPair = specificKeyPair.get();
|
||||
} else {
|
||||
Optional<PublicKey> peerKey = keyPersistenceService.resolvePublicKey(keyId);
|
||||
if (peerKey.isPresent()) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(peerKey.get())
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
log.warn(
|
||||
"Key ID {} not found in keystore, token may have been signed with an expired key",
|
||||
keyId);
|
||||
|
||||
if (keyId.equals(keyPersistenceService.getActiveKey().getKeyId())) {
|
||||
JwtVerificationKey verificationKey =
|
||||
keyPersistenceService.refreshActiveKeyPair();
|
||||
Optional<KeyPair> refreshedKeyPair =
|
||||
keyPersistenceService.getKeyPair(verificationKey.getKeyId());
|
||||
if (refreshedKeyPair.isPresent()) {
|
||||
keyPair = refreshedKeyPair.get();
|
||||
// Re-check local store before rotating: rotating a key still on disk
|
||||
// invalidates every in-flight token signed with it.
|
||||
Optional<KeyPair> localActivePair = keyPersistenceService.getKeyPair(keyId);
|
||||
if (localActivePair.isPresent()) {
|
||||
keyPair = localActivePair.get();
|
||||
} else {
|
||||
throw new AuthenticationFailureException(
|
||||
"Failed to retrieve refreshed key pair");
|
||||
// Key missing everywhere - rotate to restore signing capability.
|
||||
JwtVerificationKey verificationKey =
|
||||
keyPersistenceService.refreshActiveKeyPair();
|
||||
Optional<KeyPair> refreshedKeyPair =
|
||||
keyPersistenceService.getKeyPair(verificationKey.getKeyId());
|
||||
if (refreshedKeyPair.isPresent()) {
|
||||
keyPair = refreshedKeyPair.get();
|
||||
} else {
|
||||
throw new AuthenticationFailureException(
|
||||
"Failed to retrieve refreshed key pair");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Try to use active key as fallback
|
||||
@@ -240,7 +257,6 @@ public class JwtService implements JwtServiceInterface {
|
||||
}
|
||||
} else {
|
||||
log.debug("No key ID in token header, trying all available keys");
|
||||
// Try all available keys when no keyId is present
|
||||
return tryAllKeys(token, allowExpired);
|
||||
}
|
||||
|
||||
@@ -299,8 +315,6 @@ public class JwtService implements JwtServiceInterface {
|
||||
| NoSuchAlgorithmException
|
||||
| InvalidKeySpecException activeKeyException) {
|
||||
log.debug("Active key failed, trying all available keys from cache");
|
||||
|
||||
// If active key fails, try all available keys from cache
|
||||
List<JwtVerificationKey> allKeys =
|
||||
keyPersistenceService.getKeysEligibleForCleanup(
|
||||
LocalDateTime.now().plusDays(1));
|
||||
@@ -339,13 +353,10 @@ public class JwtService implements JwtServiceInterface {
|
||||
|
||||
@Override
|
||||
public String extractToken(HttpServletRequest request) {
|
||||
// Extract from Authorization header Bearer token
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7); // Remove "Bearer " prefix
|
||||
return token;
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -354,22 +365,11 @@ public class JwtService implements JwtServiceInterface {
|
||||
return v2Enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract key ID from JWT header without validating the token.
|
||||
*
|
||||
* <p>Parses the Base64-encoded JWT header to retrieve the "kid" (key ID) claim. Returns null if
|
||||
* the header cannot be parsed or does not contain a key ID.
|
||||
*
|
||||
* @param token the JWT token
|
||||
* @return the key ID, or null if not found or parsing fails
|
||||
*/
|
||||
/** Return the {@code kid} claim from the JWT header, or null if absent or unparseable. */
|
||||
private String extractKeyId(String token) {
|
||||
try {
|
||||
String[] tokenParts = token.split("\\.");
|
||||
if (tokenParts.length < 2) {
|
||||
log.debug(
|
||||
"Token does not have enough parts (expected at least 2, got {})",
|
||||
tokenParts.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+135
-56
@@ -1,9 +1,11 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
@@ -15,6 +17,7 @@ import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.RSAPublicKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Base64;
|
||||
@@ -28,16 +31,21 @@ import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.caffeine.CaffeineCache;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.KeyValueCache;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
|
||||
/**
|
||||
* SECURITY: the {@link #JWT_PUBKEY_NAMESPACE} cluster cache is trust-on-publish. Operators MUST
|
||||
* restrict Valkey ACL writes to app pods, enable AUTH + TLS, and network-isolate the deployment.
|
||||
* Future hardening: HMAC-signed broadcasts with a cluster master secret.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
@@ -45,18 +53,28 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
public static final String KEY_SUFFIX = ".key";
|
||||
public static final String PUB_KEY_SUFFIX = ".pub";
|
||||
|
||||
private static final Duration JWT_PUBKEY_CLUSTER_TTL = Duration.ofHours(24);
|
||||
|
||||
/** Cluster KeyValueCache namespace used to broadcast public keys to peers. */
|
||||
public static final String JWT_PUBKEY_NAMESPACE = "jwtkey";
|
||||
|
||||
private final ApplicationProperties.Security.Jwt jwtProperties;
|
||||
private final CacheManager cacheManager;
|
||||
private final Cache verifyingKeyCache;
|
||||
|
||||
private final KeyValueCache clusterKeyCache; // null in single-instance mode
|
||||
|
||||
private volatile JwtVerificationKey activeKey;
|
||||
|
||||
@Autowired
|
||||
public KeyPersistenceService(
|
||||
ApplicationProperties applicationProperties, CacheManager cacheManager) {
|
||||
ApplicationProperties applicationProperties,
|
||||
CacheManager cacheManager,
|
||||
@Autowired(required = false) KeyValueCache clusterKeyCache) {
|
||||
this.jwtProperties = applicationProperties.getSecurity().getJwt();
|
||||
this.cacheManager = cacheManager;
|
||||
this.verifyingKeyCache = cacheManager.getCache("verifyingKeys");
|
||||
this.clusterKeyCache = clusterKeyCache;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
@@ -74,12 +92,6 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all existing JWT keys from disk into memory on startup.
|
||||
*
|
||||
* <p>This ensures tokens signed with previous keys remain valid after server restart. If no
|
||||
* keys exist on disk, generates a new keypair.
|
||||
*/
|
||||
private void loadExistingKeysFromDisk() {
|
||||
try {
|
||||
Path keyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath());
|
||||
@@ -94,11 +106,8 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
try (var stream = Files.list(keyDirectory)) {
|
||||
keyFiles =
|
||||
stream.filter(path -> path.toString().endsWith(KEY_SUFFIX))
|
||||
.sorted(
|
||||
(a, b) ->
|
||||
b.getFileName().compareTo(a.getFileName())) // Most
|
||||
// recent
|
||||
// first
|
||||
// most recent first
|
||||
.sorted((a, b) -> b.getFileName().compareTo(a.getFileName()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@@ -115,11 +124,10 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
try {
|
||||
String keyId = keyFile.getFileName().toString().replace(KEY_SUFFIX, "");
|
||||
|
||||
// Load private key first
|
||||
PrivateKey privateKey = loadPrivateKey(keyId);
|
||||
|
||||
// Try to load public key, or generate it from private key if missing
|
||||
// (migration)
|
||||
// Try to load public key; generate from private key if missing (legacy
|
||||
// migration).
|
||||
String encodedPublicKey;
|
||||
try {
|
||||
encodedPublicKey = loadPublicKey(keyId);
|
||||
@@ -139,13 +147,11 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
log.info("Successfully migrated key: {}", keyId);
|
||||
}
|
||||
|
||||
// Create verification key and add to cache
|
||||
JwtVerificationKey verifyingKey =
|
||||
new JwtVerificationKey(keyId, encodedPublicKey);
|
||||
verifyingKeyCache.put(keyId, verifyingKey);
|
||||
loadedCount++;
|
||||
|
||||
// Set the most recent key as active (first in sorted list)
|
||||
if (activeKey == null) {
|
||||
activeKey = verifyingKey;
|
||||
log.info("Set active JWT signing key: {}", keyId);
|
||||
@@ -179,7 +185,6 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
private JwtVerificationKey generateAndStoreKeypair() {
|
||||
JwtVerificationKey verifyingKey = null;
|
||||
|
||||
@@ -188,9 +193,12 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
String keyId = generateKeyId();
|
||||
|
||||
storeKeyPair(keyId, keyPair);
|
||||
verifyingKey = new JwtVerificationKey(keyId, encodePublicKey(keyPair.getPublic()));
|
||||
String encodedPublicKey = encodePublicKey(keyPair.getPublic());
|
||||
verifyingKey = new JwtVerificationKey(keyId, encodedPublicKey);
|
||||
verifyingKeyCache.put(keyId, verifyingKey);
|
||||
activeKey = verifyingKey;
|
||||
// Broadcast so peer nodes can verify tokens we sign without waiting for restart.
|
||||
publishToCluster(keyId, encodedPublicKey);
|
||||
log.info("Generated and stored new JWT keypair: {}", keyId);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to generate and store keypair", e);
|
||||
@@ -199,6 +207,23 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
return verifyingKey;
|
||||
}
|
||||
|
||||
private void publishToCluster(String keyId, String encodedPublicKey) {
|
||||
if (clusterKeyCache == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
clusterKeyCache.put(
|
||||
JWT_PUBKEY_NAMESPACE, keyId, encodedPublicKey, JWT_PUBKEY_CLUSTER_TTL);
|
||||
log.info("Broadcast JWT public key to cluster KeyValueCache: {}", keyId);
|
||||
} catch (RuntimeException e) {
|
||||
// Non-fatal: we still serve tokens locally; peers catch up on their next restart.
|
||||
log.warn(
|
||||
"Failed to broadcast JWT public key {} to cluster cache: {}",
|
||||
keyId,
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JwtVerificationKey getActiveKey() {
|
||||
if (activeKey == null) {
|
||||
@@ -249,6 +274,17 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
condition = "#root.target.isKeystoreEnabled()")
|
||||
public void removeKey(String keyId) {
|
||||
verifyingKeyCache.evict(keyId);
|
||||
// Evict cluster broadcast so peers don't keep serving the removed key for up to 24h.
|
||||
if (clusterKeyCache != null && keyId != null) {
|
||||
try {
|
||||
clusterKeyCache.evict(JWT_PUBKEY_NAMESPACE, keyId);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn(
|
||||
"Failed to evict JWT public key {} from cluster cache: {}",
|
||||
keyId,
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -305,33 +341,29 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
}
|
||||
|
||||
/**
|
||||
* Store both private and public keys to disk.
|
||||
* Store both private and public keys to disk using a temp-then-atomic-rename pattern.
|
||||
*
|
||||
* <p>Private key stored as: keyId.key
|
||||
*
|
||||
* <p>Public key stored as: keyId.pub
|
||||
* <p>The caller broadcasts the public key to peers immediately after this returns. If a peer
|
||||
* learned about the keyId before the private key was fully durable, a crash mid-write followed
|
||||
* by restart would lose the key while peers still serve tokens signed with it. Writing to
|
||||
* {@code <file>.tmp} and moving with {@link StandardCopyOption#ATOMIC_MOVE} guarantees the
|
||||
* final path either contains the fully-written payload or does not exist at all.
|
||||
*/
|
||||
private void storeKeyPair(String keyId, KeyPair keyPair) throws IOException {
|
||||
Path keyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath());
|
||||
|
||||
// Store private key
|
||||
Path privateKeyFile = keyDirectory.resolve(keyId + KEY_SUFFIX);
|
||||
String encodedPrivateKey =
|
||||
Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
|
||||
Files.writeString(privateKeyFile, encodedPrivateKey);
|
||||
|
||||
// Set read/write to only the owner (security)
|
||||
writeAtomically(privateKeyFile, encodedPrivateKey);
|
||||
privateKeyFile.toFile().setReadable(true, true);
|
||||
privateKeyFile.toFile().setWritable(true, true);
|
||||
privateKeyFile.toFile().setExecutable(false, false);
|
||||
|
||||
// Store public key
|
||||
Path publicKeyFile = keyDirectory.resolve(keyId + PUB_KEY_SUFFIX);
|
||||
String encodedPublicKey =
|
||||
Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
|
||||
Files.writeString(publicKeyFile, encodedPublicKey);
|
||||
|
||||
// Public key can be more permissive but still restrict to owner
|
||||
writeAtomically(publicKeyFile, encodedPublicKey);
|
||||
publicKeyFile.toFile().setReadable(true, true);
|
||||
publicKeyFile.toFile().setWritable(true, true);
|
||||
publicKeyFile.toFile().setExecutable(false, false);
|
||||
@@ -343,6 +375,31 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
publicKeyFile.getFileName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Write {@code contents} to {@code finalPath} so that the final path either contains the full
|
||||
* payload or does not exist. Writes to a sibling {@code .tmp} file first and renames it.
|
||||
* Returns silently after falling back to a non-atomic move if the filesystem does not support
|
||||
* {@link StandardCopyOption#ATOMIC_MOVE}.
|
||||
*/
|
||||
static void writeAtomically(Path finalPath, String contents) throws IOException {
|
||||
Path tmp = finalPath.resolveSibling(finalPath.getFileName().toString() + ".tmp");
|
||||
Files.writeString(tmp, contents);
|
||||
try {
|
||||
Files.move(
|
||||
tmp,
|
||||
finalPath,
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
log.warn(
|
||||
"Filesystem does not support atomic move for {}; falling back to non-atomic"
|
||||
+ " replace. A crash between rename and fsync may leave the key partially"
|
||||
+ " written.",
|
||||
finalPath);
|
||||
Files.move(tmp, finalPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
private PrivateKey loadPrivateKey(String keyId)
|
||||
throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
|
||||
Path keyFile =
|
||||
@@ -360,13 +417,6 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
return keyFactory.generatePrivate(keySpec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load public key from disk.
|
||||
*
|
||||
* @param keyId the key identifier
|
||||
* @return Base64-encoded public key string
|
||||
* @throws IOException if the public key file is not found
|
||||
*/
|
||||
private String loadPublicKey(String keyId) throws IOException {
|
||||
Path publicKeyFile =
|
||||
Paths.get(InstallationPathConfig.getPrivateKeyPath())
|
||||
@@ -379,29 +429,12 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
return Files.readString(publicKeyFile).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a KeyPair from a PrivateKey.
|
||||
*
|
||||
* <p>For RSA keys, derives the public key from the private key.
|
||||
*
|
||||
* @param privateKey the RSA private key
|
||||
* @return reconstructed KeyPair
|
||||
* @throws NoSuchAlgorithmException if RSA algorithm is not available
|
||||
* @throws InvalidKeySpecException if the key specification is invalid
|
||||
*/
|
||||
private KeyPair reconstructKeyPair(PrivateKey privateKey)
|
||||
throws NoSuchAlgorithmException, InvalidKeySpecException {
|
||||
// For RSA, we can derive the public key from the private key
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
|
||||
// Get the private key spec
|
||||
RSAPrivateCrtKey rsaPrivateKey = (RSAPrivateCrtKey) privateKey;
|
||||
|
||||
// Create public key spec from private key parameters
|
||||
RSAPublicKeySpec publicKeySpec =
|
||||
new RSAPublicKeySpec(rsaPrivateKey.getModulus(), rsaPrivateKey.getPublicExponent());
|
||||
|
||||
// Generate public key
|
||||
PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
|
||||
|
||||
return new KeyPair(publicKey, privateKey);
|
||||
@@ -418,4 +451,50 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
return keyFactory.generatePublic(keySpec);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PublicKey> resolvePublicKey(String keyId) {
|
||||
if (keyId == null || keyId.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
// 1. Local in-memory cache (warm path - same node that signed, or already learnt).
|
||||
JwtVerificationKey local = verifyingKeyCache.get(keyId, JwtVerificationKey.class);
|
||||
if (local != null) {
|
||||
return decodeQuietly(local.getVerifyingKey(), keyId);
|
||||
}
|
||||
// 2. Local disk (cold path on restart).
|
||||
try {
|
||||
String onDisk = loadPublicKey(keyId);
|
||||
JwtVerificationKey rebuilt = new JwtVerificationKey(keyId, onDisk);
|
||||
verifyingKeyCache.put(keyId, rebuilt);
|
||||
return decodeQuietly(onDisk, keyId);
|
||||
} catch (IOException ignored) {
|
||||
// not on this node's disk - try the cluster cache
|
||||
}
|
||||
// 3. Cluster cache. NOT written to local cache: expireAfterWrite would outlive the
|
||||
// broadcast TTL and serve stale keys after peer rotation. Valkey faults return empty.
|
||||
if (clusterKeyCache != null) {
|
||||
try {
|
||||
Optional<String> remote = clusterKeyCache.get(JWT_PUBKEY_NAMESPACE, keyId);
|
||||
if (remote.isPresent()) {
|
||||
String encoded = remote.get();
|
||||
log.debug("Resolved JWT public key {} from cluster KeyValueCache", keyId);
|
||||
return decodeQuietly(encoded, keyId);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Cluster key cache unavailable for keyId {}: {}", keyId, e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private Optional<PublicKey> decodeQuietly(String encoded, String keyId) {
|
||||
try {
|
||||
return Optional.of(decodePublicKey(encoded));
|
||||
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
|
||||
log.warn("Could not decode public key for {}: {}", keyId, e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -26,4 +26,10 @@ public interface KeyPersistenceServiceInterface {
|
||||
|
||||
PublicKey decodePublicKey(String encodedKey)
|
||||
throws NoSuchAlgorithmException, InvalidKeySpecException;
|
||||
|
||||
/**
|
||||
* Resolve a public key by id, consulting the cluster cache when the key is unknown locally.
|
||||
* Returns empty if the keyId is unknown anywhere.
|
||||
*/
|
||||
Optional<PublicKey> resolvePublicKey(String keyId);
|
||||
}
|
||||
|
||||
+76
-12
@@ -5,7 +5,10 @@ import static stirling.software.proprietary.security.service.MfaService.MFA_LAST
|
||||
import static stirling.software.proprietary.security.service.MfaService.MFA_REQUIRED_KEY;
|
||||
import static stirling.software.proprietary.security.service.MfaService.MFA_SECRET_KEY;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
@@ -16,6 +19,7 @@ import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.apache.commons.codec.digest.DigestUtils;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.context.i18n.LocaleContextHolder;
|
||||
@@ -34,6 +38,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.KeyValueCache;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
@@ -79,6 +84,13 @@ public class UserService implements UserServiceInterface {
|
||||
|
||||
private final ApplicationProperties.Security.OAUTH2 oAuth2;
|
||||
|
||||
private final KeyValueCache keyValueCache;
|
||||
|
||||
private static final String API_KEY_CACHE_NS = "apikey";
|
||||
private static final Duration API_KEY_TTL = Duration.ofSeconds(60);
|
||||
private static final Duration API_KEY_NEGATIVE_TTL = Duration.ofSeconds(10);
|
||||
private static final String API_KEY_NEGATIVE_MARKER = "__none__";
|
||||
|
||||
private final PersistentLoginRepository persistentLoginRepository;
|
||||
private final UserServerCertificateService userServerCertificateService;
|
||||
private final WorkflowParticipantRepository workflowParticipantRepository;
|
||||
@@ -143,11 +155,7 @@ public class UserService implements UserServiceInterface {
|
||||
if (user.isEmpty()) {
|
||||
throw new UsernameNotFoundException("API key is not valid");
|
||||
}
|
||||
// Convert the user into an Authentication object
|
||||
return new UsernamePasswordAuthenticationToken( // principal (typically the user)
|
||||
user, // credentials (we don't expose the password or API key here)
|
||||
null, // user's authorities (roles/permissions)
|
||||
getAuthorities(user.get()));
|
||||
return new UsernamePasswordAuthenticationToken(user, null, getAuthorities(user.get()));
|
||||
}
|
||||
|
||||
private Collection<? extends GrantedAuthority> getAuthorities(User user) {
|
||||
@@ -176,14 +184,19 @@ public class UserService implements UserServiceInterface {
|
||||
|
||||
private User saveUser(Optional<User> user, String apiKey) {
|
||||
if (user.isPresent()) {
|
||||
String previousKey = user.get().getApiKey();
|
||||
user.get().setApiKey(apiKey);
|
||||
return userRepository.save(user.get());
|
||||
User saved = userRepository.save(user.get());
|
||||
// Evict the previously cached entry so peers see the rotation within negative TTL.
|
||||
if (previousKey != null && !previousKey.isBlank()) {
|
||||
evictApiKeyCache(previousKey);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
throw new UsernameNotFoundException("User not found");
|
||||
}
|
||||
|
||||
public User refreshApiKeyForUser(String username) {
|
||||
// reuse the add API key method for refreshing
|
||||
return addApiKeyToUser(username);
|
||||
}
|
||||
|
||||
@@ -208,25 +221,71 @@ public class UserService implements UserServiceInterface {
|
||||
}
|
||||
|
||||
public boolean isValidApiKey(String apiKey) {
|
||||
return userRepository.findByApiKey(apiKey).isPresent();
|
||||
return getUserByApiKey(apiKey).isPresent();
|
||||
}
|
||||
|
||||
public Optional<User> getUserByApiKey(String apiKey) {
|
||||
return userRepository.findByApiKey(apiKey);
|
||||
return findByApiKeyCached(apiKey);
|
||||
}
|
||||
|
||||
public Optional<User> loadUserByApiKey(String apiKey) {
|
||||
Optional<User> user = userRepository.findByApiKey(apiKey);
|
||||
Optional<User> user = findByApiKeyCached(apiKey);
|
||||
if (user.isPresent()) {
|
||||
return user;
|
||||
}
|
||||
// or throw an exception
|
||||
return null;
|
||||
}
|
||||
|
||||
private Optional<User> findByApiKeyCached(String apiKey) {
|
||||
if (apiKey == null || apiKey.isBlank() || keyValueCache == null) {
|
||||
return userRepository.findByApiKey(apiKey);
|
||||
}
|
||||
String keyHash = DigestUtils.sha256Hex(apiKey);
|
||||
Optional<String> cached = keyValueCache.get(API_KEY_CACHE_NS, keyHash);
|
||||
if (cached.isPresent()) {
|
||||
String value = cached.get();
|
||||
if (API_KEY_NEGATIVE_MARKER.equals(value)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Optional<User> user = userRepository.findByUsernameIgnoreCase(value);
|
||||
if (user.isPresent() && constantTimeEquals(apiKey, user.get().getApiKey())) {
|
||||
return user;
|
||||
}
|
||||
keyValueCache.evict(API_KEY_CACHE_NS, keyHash);
|
||||
}
|
||||
Optional<User> user = userRepository.findByApiKey(apiKey);
|
||||
if (user.isPresent()) {
|
||||
keyValueCache.put(API_KEY_CACHE_NS, keyHash, user.get().getUsername(), API_KEY_TTL);
|
||||
} else {
|
||||
keyValueCache.put(
|
||||
API_KEY_CACHE_NS, keyHash, API_KEY_NEGATIVE_MARKER, API_KEY_NEGATIVE_TTL);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Invalidate the cached API-key entry on rotation. */
|
||||
public void evictApiKeyCache(String apiKey) {
|
||||
if (apiKey != null && !apiKey.isBlank() && keyValueCache != null) {
|
||||
keyValueCache.evict(API_KEY_CACHE_NS, DigestUtils.sha256Hex(apiKey));
|
||||
}
|
||||
}
|
||||
|
||||
public boolean validateApiKeyForUser(String username, String apiKey) {
|
||||
Optional<User> userOpt = findByUsernameIgnoreCase(username);
|
||||
return userOpt.isPresent() && apiKey.equals(userOpt.get().getApiKey());
|
||||
return userOpt.isPresent() && constantTimeEquals(apiKey, userOpt.get().getApiKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time comparison of two API keys. {@link MessageDigest#isEqual} short-circuits only
|
||||
* on null/empty inputs; for equal-length and unequal-length non-empty strings it scans every
|
||||
* byte, so a remote attacker cannot infer the stored key via response-time differences.
|
||||
*/
|
||||
private static boolean constantTimeEquals(String provided, String stored) {
|
||||
if (provided == null || stored == null) {
|
||||
return false;
|
||||
}
|
||||
return MessageDigest.isEqual(
|
||||
provided.getBytes(StandardCharsets.UTF_8), stored.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -706,8 +765,13 @@ public class UserService implements UserServiceInterface {
|
||||
User updatedUser = existingUser.get();
|
||||
|
||||
if (!customApiKey.equals(updatedUser.getApiKey())) {
|
||||
// Capture before mutation so we can evict the prior cache entry.
|
||||
String previousKey = updatedUser.getApiKey();
|
||||
updatedUser.setApiKey(customApiKey);
|
||||
userRepository.save(updatedUser);
|
||||
if (previousKey != null && !previousKey.isBlank()) {
|
||||
evictApiKeyCache(previousKey);
|
||||
}
|
||||
}
|
||||
},
|
||||
() -> {
|
||||
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
package stirling.software.proprietary.storage.config;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
|
||||
|
||||
/**
|
||||
* Fails fast at boot if cluster mode is enabled with node-local storage. Validates both {@code
|
||||
* storage.provider} (persistent uploads) and {@code cluster.artifactStore} (transient job-result
|
||||
* files): neither may be {@code local} when {@code cluster.enabled=true}. Additionally enforces
|
||||
* that any S3-backed configuration ({@code storage.provider=s3} or {@code
|
||||
* cluster.artifactStore=s3}) is accompanied by a valid Pro / Enterprise license.
|
||||
*/
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ClusterStorageGate {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final LicenseKeyChecker licenseKeyChecker;
|
||||
|
||||
@Value("${cluster.enabled:false}")
|
||||
private boolean clusterEnabled;
|
||||
|
||||
@Value("${cluster.artifactStore:local}")
|
||||
private String clusterArtifactStore;
|
||||
|
||||
@PostConstruct
|
||||
void validate() {
|
||||
// License enforcement runs regardless of cluster.enabled: even a single-node setup that
|
||||
// selects a remote backend must hold a Pro or higher license.
|
||||
ApplicationProperties.Storage storage = applicationProperties.getStorage();
|
||||
if (storage != null && storage.isEnabled()) {
|
||||
String provider = normalize(storage.getProvider());
|
||||
if ("s3".equals(provider) || "database".equals(provider)) {
|
||||
licenseKeyChecker.requireProOrEnterprise("storage.provider=" + provider);
|
||||
}
|
||||
}
|
||||
if ("s3".equals(normalize(clusterArtifactStore))) {
|
||||
licenseKeyChecker.requireProOrEnterprise("cluster.artifactStore=s3");
|
||||
}
|
||||
|
||||
if (!clusterEnabled) {
|
||||
return;
|
||||
}
|
||||
if (storage != null && storage.isEnabled()) {
|
||||
validate(
|
||||
"storage.provider",
|
||||
storage.getProvider(),
|
||||
"Local filesystem storage cannot be shared across cluster nodes."
|
||||
+ " Configure storage.provider=s3 (with storage.s3.bucket /"
|
||||
+ " endpoint / credentials) or storage.provider=database before"
|
||||
+ " enabling clustering.");
|
||||
}
|
||||
validate(
|
||||
"cluster.artifactStore",
|
||||
clusterArtifactStore,
|
||||
"Per-node disk cannot back transient job-result files in a multi-node"
|
||||
+ " deployment; downloads would 404 whenever the load balancer routes"
|
||||
+ " a follow-up request to a different node. Configure"
|
||||
+ " cluster.artifactStore=s3 (reuses storage.s3.* config)"
|
||||
+ " before enabling clustering.");
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return Optional.ofNullable(value).orElse("local").trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static void validate(String propertyName, String configuredValue, String remediation) {
|
||||
String normalized =
|
||||
Optional.ofNullable(configuredValue)
|
||||
.orElse("local")
|
||||
.trim()
|
||||
.toLowerCase(Locale.ROOT);
|
||||
if ("local".equals(normalized)) {
|
||||
throw new IllegalStateException(
|
||||
"Cluster mode (cluster.enabled=true) is incompatible with "
|
||||
+ propertyName
|
||||
+ "=local. "
|
||||
+ remediation);
|
||||
}
|
||||
log.info(
|
||||
"Cluster storage gate: clusterEnabled=true, {}={} -> OK", propertyName, normalized);
|
||||
}
|
||||
}
|
||||
+1
-15
@@ -15,11 +15,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.cluster.s3.S3Clients;
|
||||
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
|
||||
import stirling.software.proprietary.storage.provider.DatabaseStorageProvider;
|
||||
import stirling.software.proprietary.storage.provider.LocalStorageProvider;
|
||||
import stirling.software.proprietary.storage.provider.S3StorageProvider;
|
||||
import stirling.software.proprietary.storage.provider.StorageProvider;
|
||||
import stirling.software.proprietary.storage.repository.StoredFileBlobRepository;
|
||||
|
||||
@@ -30,9 +27,8 @@ public class StorageProviderConfig {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final StoredFileBlobRepository storedFileBlobRepository;
|
||||
private final LicenseKeyChecker licenseKeyChecker;
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
@Bean
|
||||
public StorageProvider storageProvider() {
|
||||
boolean storageEnabled = applicationProperties.getStorage().isEnabled();
|
||||
String providerName =
|
||||
@@ -41,13 +37,8 @@ public class StorageProviderConfig {
|
||||
.trim()
|
||||
.toLowerCase(Locale.ROOT);
|
||||
if ("database".equals(providerName)) {
|
||||
licenseKeyChecker.requireProOrEnterprise("storage.provider=database");
|
||||
return new DatabaseStorageProvider(storedFileBlobRepository);
|
||||
}
|
||||
if ("s3".equals(providerName)) {
|
||||
licenseKeyChecker.requireProOrEnterprise("storage.provider=s3");
|
||||
return buildS3Provider(applicationProperties.getStorage().getS3());
|
||||
}
|
||||
if (!"local".equals(providerName)) {
|
||||
throw new IllegalStateException("Storage provider not supported: " + providerName);
|
||||
}
|
||||
@@ -80,9 +71,4 @@ public class StorageProviderConfig {
|
||||
}
|
||||
return new LocalStorageProvider(basePath);
|
||||
}
|
||||
|
||||
private S3StorageProvider buildS3Provider(ApplicationProperties.Storage.S3 cfg) {
|
||||
S3Clients.Bundle bundle = S3Clients.build(cfg, "storage provider");
|
||||
return new S3StorageProvider(bundle.client(), bundle.presigner(), cfg.getBucket());
|
||||
}
|
||||
}
|
||||
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
package stirling.software.proprietary.storage.controller;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.storage.service.FolderService;
|
||||
|
||||
/**
|
||||
* Folder placement endpoints for existing stored files. Thin adapter: validates the request shape,
|
||||
* delegates the transaction to {@link FolderService}, then maps the result onto the HTTP status.
|
||||
* Authentication, storage-gate, ownership checks, and the bulk cap all live on the service (where
|
||||
* {@code @Transactional} also lives) so the JDBC connection isn't held through JSON serialization.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/storage/files")
|
||||
@RequiredArgsConstructor
|
||||
public class FileFolderPlacementController {
|
||||
|
||||
private static final int BULK_MOVE_MAX_FILES = 1000;
|
||||
|
||||
private final FolderService folderService;
|
||||
|
||||
/** Move a single file to a folder (or to root when folderId is null). */
|
||||
@PatchMapping("/{fileId}/folder")
|
||||
public ResponseEntity<Void> moveFileToFolder(
|
||||
@PathVariable Long fileId, @Valid @RequestBody FolderPlacement body) {
|
||||
folderService.moveFileToFolder(fileId, body.getFolderId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk move - fewer round-trips than calling the single endpoint N times. Returns 200 on full
|
||||
* success, 207 (Multi-Status) when some files were skipped (typically because they don't belong
|
||||
* to the caller).
|
||||
*/
|
||||
@PatchMapping("/folder")
|
||||
public ResponseEntity<BulkMoveResponse> bulkMove(@Valid @RequestBody BulkMoveRequest body) {
|
||||
FolderService.BulkMoveResult result =
|
||||
folderService.bulkMoveFilesToFolder(body.getFolderId(), body.getFileIds());
|
||||
HttpStatus status =
|
||||
result.skippedFileIds().isEmpty() ? HttpStatus.OK : HttpStatus.MULTI_STATUS;
|
||||
return ResponseEntity.status(status)
|
||||
.body(new BulkMoveResponse(result.movedFileIds(), result.skippedFileIds()));
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class FolderPlacement {
|
||||
private UUID folderId;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class BulkMoveRequest {
|
||||
private UUID folderId;
|
||||
|
||||
@NotNull
|
||||
@Size(
|
||||
min = 1,
|
||||
max = BULK_MOVE_MAX_FILES,
|
||||
message = "fileIds must contain between 1 and 1000 entries")
|
||||
private List<Long> fileIds;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class BulkMoveResponse {
|
||||
private List<Long> movedFileIds;
|
||||
private List<Long> skippedFileIds;
|
||||
}
|
||||
}
|
||||
+2
-46
@@ -1,11 +1,7 @@
|
||||
package stirling.software.proprietary.storage.controller;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -29,7 +25,6 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.storage.model.FileShare;
|
||||
@@ -40,22 +35,17 @@ import stirling.software.proprietary.storage.model.api.ShareLinkMetadataResponse
|
||||
import stirling.software.proprietary.storage.model.api.ShareLinkResponse;
|
||||
import stirling.software.proprietary.storage.model.api.ShareWithUserRequest;
|
||||
import stirling.software.proprietary.storage.model.api.StoredFileResponse;
|
||||
import stirling.software.proprietary.storage.provider.StorageProvider;
|
||||
import stirling.software.proprietary.storage.service.FileStorageService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/storage")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Tag(
|
||||
name = "File Storage",
|
||||
description = "Stored file management, sharing, and share link operations")
|
||||
public class FileStorageController {
|
||||
|
||||
private static final Duration SIGNED_URL_TTL = Duration.ofMinutes(5);
|
||||
|
||||
private final FileStorageService fileStorageService;
|
||||
private final StorageProvider storageProvider;
|
||||
|
||||
@PostMapping(
|
||||
value = "/files",
|
||||
@@ -101,9 +91,7 @@ public class FileStorageController {
|
||||
User user = fileStorageService.requireAuthenticatedUser();
|
||||
StoredFile file = fileStorageService.getAccessibleFile(user, fileId);
|
||||
fileStorageService.requireReadAccess(user, file);
|
||||
Optional<ResponseEntity<org.springframework.core.io.Resource>> redirect =
|
||||
tryRedirectToSignedUrl(file, inline);
|
||||
return redirect.orElseGet(() -> buildFileResponse(file, inline));
|
||||
return buildFileResponse(file, inline);
|
||||
}
|
||||
|
||||
@DeleteMapping("/files/{fileId}")
|
||||
@@ -201,9 +189,7 @@ public class FileStorageController {
|
||||
fileStorageService.requireReadAccess(share);
|
||||
fileStorageService.recordShareAccess(share, authentication, inline);
|
||||
StoredFile file = share.getFile();
|
||||
Optional<ResponseEntity<org.springframework.core.io.Resource>> redirect =
|
||||
tryRedirectToSignedUrl(file, inline);
|
||||
return redirect.orElseGet(() -> buildFileResponse(file, inline));
|
||||
return buildFileResponse(file, inline);
|
||||
}
|
||||
|
||||
@GetMapping("/share-links/{token}/metadata")
|
||||
@@ -286,34 +272,4 @@ public class FileStorageController {
|
||||
&& authentication.isAuthenticated()
|
||||
&& !"anonymousUser".equals(authentication.getPrincipal());
|
||||
}
|
||||
|
||||
private Optional<ResponseEntity<org.springframework.core.io.Resource>> tryRedirectToSignedUrl(
|
||||
StoredFile file, boolean inline) {
|
||||
if (file == null || file.getStorageKey() == null || file.getStorageKey().isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
Optional<URI> signed =
|
||||
storageProvider.signedDownloadUrl(
|
||||
file.getStorageKey(),
|
||||
SIGNED_URL_TTL,
|
||||
inline,
|
||||
file.getOriginalFilename());
|
||||
if (signed.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setLocation(signed.get());
|
||||
ResponseEntity<org.springframework.core.io.Resource> response =
|
||||
ResponseEntity.status(HttpStatus.FOUND).headers(headers).build();
|
||||
return Optional.of(response);
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to create signed download URL for file {} (key: {}), falling back to streaming",
|
||||
file.getId(),
|
||||
file.getStorageKey(),
|
||||
e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
package stirling.software.proprietary.storage.controller;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.storage.model.api.CreateFolderRequest;
|
||||
import stirling.software.proprietary.storage.model.api.FolderResponse;
|
||||
import stirling.software.proprietary.storage.model.api.UpdateFolderRequest;
|
||||
import stirling.software.proprietary.storage.service.FolderService;
|
||||
|
||||
/**
|
||||
* REST endpoints for user-owned folders. Phase A - no folder-level sharing yet (Phase 3).
|
||||
*
|
||||
* <p>All operations are scoped to the authenticated user; existing single-file storage endpoints in
|
||||
* {@link FileStorageController} are left alone so the cert-signing and standard upload flows are
|
||||
* unaffected.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/storage/folders")
|
||||
@RequiredArgsConstructor
|
||||
public class FolderController {
|
||||
|
||||
private final FolderService folderService;
|
||||
|
||||
@GetMapping
|
||||
public List<FolderResponse> listFolders() {
|
||||
return folderService.listFolders();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<FolderResponse> createFolder(
|
||||
@Valid @RequestBody CreateFolderRequest request) {
|
||||
FolderResponse response = folderService.createFolder(request);
|
||||
// 201 Created with Location header - conventional REST. The idempotent re-return path
|
||||
// (same id resubmitted) also lands here; treating it as 201 keeps wire semantics simple.
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.location(URI.create("/api/v1/storage/folders/" + response.id()))
|
||||
.body(response);
|
||||
}
|
||||
|
||||
@PatchMapping("/{folderId}")
|
||||
public ResponseEntity<FolderResponse> updateFolder(
|
||||
@PathVariable UUID folderId, @Valid @RequestBody UpdateFolderRequest request) {
|
||||
return ResponseEntity.ok(folderService.updateFolder(folderId, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{folderId}")
|
||||
public ResponseEntity<DeleteFolderResponse> deleteFolder(@PathVariable UUID folderId) {
|
||||
List<UUID> removed = folderService.deleteFolder(folderId);
|
||||
return ResponseEntity.ok(new DeleteFolderResponse(removed));
|
||||
}
|
||||
|
||||
public record DeleteFolderResponse(List<UUID> removedFolderIds) {}
|
||||
}
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
package stirling.software.proprietary.storage.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
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.Id;
|
||||
import jakarta.persistence.Index;
|
||||
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;
|
||||
|
||||
/**
|
||||
* A user-owned folder used by the file manager UI to organise stored files. Phase A entity - no
|
||||
* folder-level sharing yet (Phase 3).
|
||||
*
|
||||
* <p>The id is a UUID rather than a numeric auto-increment so it round-trips with the
|
||||
* client-generated {@code FolderId} and survives cross-device sync without re-keying.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "folders",
|
||||
indexes = {
|
||||
@Index(name = "idx_folders_owner", columnList = "owner_id"),
|
||||
@Index(name = "idx_folders_parent", columnList = "parent_folder_id"),
|
||||
@Index(name = "idx_folders_owner_parent", columnList = "owner_id, parent_folder_id")
|
||||
})
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class Folder implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* Dialect-portable UUID column. The previous {@code columnDefinition = "uuid"} was
|
||||
* Postgres-specific and broke on H2/MariaDB. Hibernate's {@code UUID} mapping picks the right
|
||||
* native type per dialect (BINARY(16) on H2/MariaDB, uuid on Postgres) when no explicit
|
||||
* columnDefinition is set.
|
||||
*/
|
||||
@Id
|
||||
@Column(name = "folder_id", nullable = false)
|
||||
private UUID id;
|
||||
|
||||
/**
|
||||
* {@code OnDeleteAction.CASCADE} so deleting the owning {@code User} cascades to this row at
|
||||
* the DB level - UserService.deleteUserRelatedData doesn't enumerate folders today, and leaving
|
||||
* the FK without an action throws a constraint violation on user delete.
|
||||
*/
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "owner_id", nullable = false)
|
||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||
private User owner;
|
||||
|
||||
/**
|
||||
* Parent folder; null = root. {@code OnDeleteAction.CASCADE} so a backend-side parent delete
|
||||
* cleans children automatically, matching the service-layer recursive-delete contract.
|
||||
*/
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "parent_folder_id")
|
||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||
private Folder parent;
|
||||
|
||||
@Column(name = "name", nullable = false, length = 255)
|
||||
private String name;
|
||||
|
||||
@Column(name = "color", length = 32)
|
||||
private String color;
|
||||
|
||||
@Column(name = "icon", length = 64)
|
||||
private String icon;
|
||||
|
||||
/**
|
||||
* Optimistic-locking version. Cross-PC sync without this lets last-write-win silently. The
|
||||
* column is nullable so existing rows from a pre-version deployment can be backfilled by
|
||||
* Hibernate's update-on-write rather than failing the ddl-auto upgrade.
|
||||
*/
|
||||
@Version
|
||||
@Column(name = "version")
|
||||
private Long version;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+1
-18
@@ -6,8 +6,6 @@ import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.OnDelete;
|
||||
import org.hibernate.annotations.OnDeleteAction;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.CascadeType;
|
||||
@@ -37,8 +35,7 @@ import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||
name = "stored_files",
|
||||
indexes = {
|
||||
@Index(name = "idx_stored_files_owner", columnList = "owner_id"),
|
||||
@Index(name = "idx_stored_files_workflow", columnList = "workflow_session_id"),
|
||||
@Index(name = "idx_stored_files_folder", columnList = "folder_id")
|
||||
@Index(name = "idx_stored_files_workflow", columnList = "workflow_session_id")
|
||||
})
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@@ -109,20 +106,6 @@ public class StoredFile implements Serializable {
|
||||
orphanRemoval = true)
|
||||
private Set<FileShare> shares = new HashSet<>();
|
||||
|
||||
/**
|
||||
* Optional folder placement for the file manager UI. Null = root. Hibernate ddl-auto will add
|
||||
* this as a nullable column on upgrade so existing records continue to work untouched.
|
||||
*
|
||||
* <p>{@code OnDeleteAction.SET_NULL} so any backend that drops a folder row (admin script,
|
||||
* future cleanup job, cascading user delete) cleanly orphans files to root rather than leaving
|
||||
* dangling FK references. The application path ({@code FolderRepository.clearFolderForFiles})
|
||||
* still runs first as a belt-and-braces.
|
||||
*/
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "folder_id")
|
||||
@OnDelete(action = OnDeleteAction.SET_NULL)
|
||||
private Folder folder;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
package stirling.software.proprietary.storage.model.api;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CreateFolderRequest {
|
||||
|
||||
/**
|
||||
* Client-generated UUID - lets the caller round-trip the same id it stored locally. Optional;
|
||||
* the server generates one when missing.
|
||||
*/
|
||||
private UUID id;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 255)
|
||||
private String name;
|
||||
|
||||
private UUID parentFolderId;
|
||||
|
||||
/** Hex colour string (#rrggbb or #rrggbbaa) - matches the frontend palette format. */
|
||||
@Size(max = 32)
|
||||
@Pattern(
|
||||
regexp = "^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$",
|
||||
message = "color must be a #RRGGBB or #RRGGBBAA hex value")
|
||||
private String color;
|
||||
|
||||
/** Icon identifier - lowercase alphanumerics, hyphens, underscores only. */
|
||||
@Size(max = 64)
|
||||
@Pattern(
|
||||
regexp = "^[a-z0-9_-]+$",
|
||||
message = "icon must be a lowercase id (a-z, 0-9, '-' or '_')")
|
||||
private String icon;
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
package stirling.software.proprietary.storage.model.api;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import stirling.software.proprietary.storage.model.Folder;
|
||||
|
||||
/**
|
||||
* Outbound DTO for folder responses. Records are immutable, value-equality-based, and far less
|
||||
* accident-prone than a {@code @Data} class with public setters.
|
||||
*/
|
||||
public record FolderResponse(
|
||||
UUID id,
|
||||
String name,
|
||||
UUID parentFolderId,
|
||||
String color,
|
||||
String icon,
|
||||
Long version,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt) {
|
||||
|
||||
public static FolderResponse from(Folder folder) {
|
||||
// {@code folder.getParent().getId()} on a lazy proxy returns the FK value cached at the
|
||||
// join column WITHOUT initialising the proxy under standard Hibernate, so this does
|
||||
// not N+1. If a future Hibernate update changes that, switch the JPQL list query to a
|
||||
// constructor projection.
|
||||
UUID parentId = folder.getParent() == null ? null : folder.getParent().getId();
|
||||
return new FolderResponse(
|
||||
folder.getId(),
|
||||
folder.getName(),
|
||||
parentId,
|
||||
folder.getColor(),
|
||||
folder.getIcon(),
|
||||
folder.getVersion(),
|
||||
folder.getCreatedAt(),
|
||||
folder.getUpdatedAt());
|
||||
}
|
||||
}
|
||||
-7
@@ -2,7 +2,6 @@ package stirling.software.proprietary.storage.model.api;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
@@ -23,10 +22,4 @@ public class StoredFileResponse {
|
||||
private final List<SharedUserResponse> sharedUsers;
|
||||
private final List<ShareLinkResponse> shareLinks;
|
||||
private final String filePurpose;
|
||||
|
||||
/**
|
||||
* Optional folder placement (Phase A). Null when the file lives at the root or when the server
|
||||
* build doesn't have the folders feature enabled - existing clients should treat null as root.
|
||||
*/
|
||||
private final UUID folderId;
|
||||
}
|
||||
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
package stirling.software.proprietary.storage.model.api;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* PATCH-style update - every field is optional. Send only the fields you want to change.
|
||||
*
|
||||
* <p>The {@code reparent} flag distinguishes "do not change parent" from "move to root" since
|
||||
* {@code parentFolderId == null} alone is ambiguous in a sparse body. We use a boxed {@link
|
||||
* Boolean} so a missing field deserialises to {@code null} (= "do not reparent") rather than to
|
||||
* primitive {@code false}, removing a class of "I PATCHed only the name but the server reset my
|
||||
* parent" footguns.
|
||||
*
|
||||
* <p>When the trimmed name is empty (e.g. {@code " "}) the service rejects the request with HTTP
|
||||
* 400 - silent drops are too easy to mistake for a successful rename.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UpdateFolderRequest {
|
||||
|
||||
/** When provided, must contain at least one non-whitespace character. */
|
||||
@Size(max = 255)
|
||||
@Pattern(regexp = "\\S.*", message = "name must not be blank")
|
||||
private String name;
|
||||
|
||||
private Boolean reparent;
|
||||
private UUID parentFolderId;
|
||||
|
||||
@Size(max = 32)
|
||||
@Pattern(
|
||||
regexp = "^(|#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?)$",
|
||||
message = "color must be empty or a #RRGGBB / #RRGGBBAA hex value")
|
||||
private String color;
|
||||
|
||||
@Size(max = 64)
|
||||
@Pattern(
|
||||
regexp = "^([a-z0-9_-]+)?$",
|
||||
message = "icon must be a lowercase id (a-z, 0-9, '-' or '_') or empty")
|
||||
private String icon;
|
||||
|
||||
/**
|
||||
* Convenience accessor - treats null as "do not reparent". Named differently from the
|
||||
* Lombok-generated {@code getReparent()} so callers don't accidentally use one for the other
|
||||
* (the getter is nullable {@code Boolean}; this method collapses to primitive).
|
||||
*/
|
||||
@com.fasterxml.jackson.annotation.JsonIgnore
|
||||
public boolean shouldReparent() {
|
||||
return Boolean.TRUE.equals(reparent);
|
||||
}
|
||||
}
|
||||
+1
-5
@@ -24,9 +24,6 @@ public class LocalStorageProvider implements StorageProvider {
|
||||
|
||||
@Override
|
||||
public StoredObject store(User owner, MultipartFile file) throws IOException {
|
||||
if (owner == null || owner.getId() == null) {
|
||||
throw new IllegalArgumentException("owner.id is required for local storage key");
|
||||
}
|
||||
String originalFilename = sanitizeFilename(file.getOriginalFilename());
|
||||
String storageKey =
|
||||
owner.getId()
|
||||
@@ -80,7 +77,6 @@ public class LocalStorageProvider implements StorageProvider {
|
||||
if (filename == null || filename.isBlank()) {
|
||||
return "file";
|
||||
}
|
||||
String stripped = Paths.get(filename).getFileName().toString().replaceAll("\\p{Cntrl}", "");
|
||||
return stripped.isBlank() ? "file" : stripped;
|
||||
return Paths.get(filename).getFileName().toString();
|
||||
}
|
||||
}
|
||||
|
||||
-190
@@ -1,190 +0,0 @@
|
||||
package stirling.software.proprietary.storage.provider;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
import software.amazon.awssdk.core.ResponseInputStream;
|
||||
import software.amazon.awssdk.core.exception.SdkException;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
|
||||
|
||||
/** {@link StorageProvider} backed by an S3-compatible object store. */
|
||||
@Slf4j
|
||||
public class S3StorageProvider implements StorageProvider, AutoCloseable {
|
||||
|
||||
private final S3Client s3Client;
|
||||
private final S3Presigner s3Presigner;
|
||||
private final String bucket;
|
||||
|
||||
public S3StorageProvider(S3Client s3Client, S3Presigner s3Presigner, String bucket) {
|
||||
if (bucket == null || bucket.isBlank()) {
|
||||
throw new IllegalArgumentException("S3 bucket must be configured");
|
||||
}
|
||||
this.s3Client = s3Client;
|
||||
this.s3Presigner = s3Presigner;
|
||||
this.bucket = bucket;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StoredObject store(User owner, MultipartFile file) throws IOException {
|
||||
if (owner == null || owner.getId() == null) {
|
||||
throw new IllegalArgumentException("owner.id is required for S3 storage key");
|
||||
}
|
||||
String originalFilename = sanitizeFilename(file.getOriginalFilename());
|
||||
// Key is opaque ({ownerId}/{uuid}) so non-ASCII filenames don't break vendors that
|
||||
// restrict key charset (e.g. Supabase Storage returns 400 Invalid key on unicode).
|
||||
// The display name is preserved in StoredObject.originalFilename and the DB row.
|
||||
String storageKey = owner.getId() + "/" + UUID.randomUUID();
|
||||
|
||||
PutObjectRequest.Builder request =
|
||||
PutObjectRequest.builder().bucket(bucket).key(storageKey);
|
||||
if (file.getContentType() != null && !file.getContentType().isBlank()) {
|
||||
request.contentType(file.getContentType());
|
||||
}
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
s3Client.putObject(
|
||||
request.build(), RequestBody.fromInputStream(inputStream, file.getSize()));
|
||||
} catch (SdkException e) {
|
||||
throw new IOException("Failed to upload object to S3", e);
|
||||
}
|
||||
|
||||
return StoredObject.builder()
|
||||
.storageKey(storageKey)
|
||||
.originalFilename(originalFilename)
|
||||
.contentType(file.getContentType())
|
||||
.sizeBytes(file.getSize())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Resource load(String storageKey) throws IOException {
|
||||
GetObjectRequest request =
|
||||
GetObjectRequest.builder().bucket(bucket).key(storageKey).build();
|
||||
try {
|
||||
ResponseInputStream<GetObjectResponse> stream = s3Client.getObject(request);
|
||||
long contentLength =
|
||||
stream.response().contentLength() != null
|
||||
? stream.response().contentLength()
|
||||
: -1;
|
||||
return new InputStreamResource(stream) {
|
||||
@Override
|
||||
public long contentLength() {
|
||||
return contentLength;
|
||||
}
|
||||
};
|
||||
} catch (NoSuchKeyException e) {
|
||||
throw new IOException("File not found", e);
|
||||
} catch (SdkException e) {
|
||||
throw new IOException("Failed to load object from S3", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String storageKey) throws IOException {
|
||||
try {
|
||||
s3Client.deleteObject(
|
||||
DeleteObjectRequest.builder().bucket(bucket).key(storageKey).build());
|
||||
} catch (SdkException e) {
|
||||
throw new IOException("Failed to delete object from S3", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<URI> signedDownloadUrl(String storageKey, Duration ttl) throws IOException {
|
||||
return signedDownloadUrl(storageKey, ttl, false, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<URI> signedDownloadUrl(
|
||||
String storageKey, Duration ttl, boolean inline, String originalFilename)
|
||||
throws IOException {
|
||||
if (storageKey == null || storageKey.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Duration effectiveTtl =
|
||||
ttl == null || ttl.isZero() || ttl.isNegative() ? Duration.ofMinutes(5) : ttl;
|
||||
try {
|
||||
GetObjectRequest.Builder getBuilder =
|
||||
GetObjectRequest.builder().bucket(bucket).key(storageKey);
|
||||
String disposition = buildContentDisposition(inline, originalFilename);
|
||||
if (disposition != null) {
|
||||
getBuilder.responseContentDisposition(disposition);
|
||||
}
|
||||
GetObjectPresignRequest presignRequest =
|
||||
GetObjectPresignRequest.builder()
|
||||
.signatureDuration(effectiveTtl)
|
||||
.getObjectRequest(getBuilder.build())
|
||||
.build();
|
||||
PresignedGetObjectRequest presigned = s3Presigner.presignGetObject(presignRequest);
|
||||
return Optional.of(presigned.url().toURI());
|
||||
} catch (SdkException | URISyntaxException e) {
|
||||
log.warn("Failed to create presigned S3 GET URL for key {}", storageKey, e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
// Returns null when originalFilename is blank; S3 falls back to its own default in that case.
|
||||
static String buildContentDisposition(boolean inline, String originalFilename) {
|
||||
if (originalFilename == null || originalFilename.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
// Strip CR/LF and other control chars before path parsing (Paths.get throws on them on
|
||||
// Windows, and they defeat header parsers).
|
||||
String stripped = originalFilename.replaceAll("\\p{Cntrl}", "");
|
||||
// Use only the basename to avoid leaking directory structure into the header.
|
||||
int lastSeparator = Math.max(stripped.lastIndexOf('/'), stripped.lastIndexOf('\\'));
|
||||
if (lastSeparator >= 0) {
|
||||
stripped = stripped.substring(lastSeparator + 1);
|
||||
}
|
||||
if (stripped.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
// Escape per RFC 6266 quoted-string rules.
|
||||
String escaped = stripped.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
return (inline ? "inline" : "attachment") + "; filename=\"" + escaped + "\"";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
try {
|
||||
s3Presigner.close();
|
||||
} catch (Exception e) {
|
||||
log.warn("Error closing S3 presigner", e);
|
||||
}
|
||||
try {
|
||||
s3Client.close();
|
||||
} catch (Exception e) {
|
||||
log.warn("Error closing S3 client", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitizeFilename(String filename) {
|
||||
if (filename == null || filename.isBlank()) {
|
||||
return "file";
|
||||
}
|
||||
String stripped = Paths.get(filename).getFileName().toString().replaceAll("\\p{Cntrl}", "");
|
||||
return stripped.isBlank() ? "file" : stripped;
|
||||
}
|
||||
}
|
||||
+1
-30
@@ -1,45 +1,16 @@
|
||||
package stirling.software.proprietary.storage.provider;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
public interface StorageProvider extends AutoCloseable {
|
||||
public interface StorageProvider {
|
||||
StoredObject store(User owner, MultipartFile file) throws IOException;
|
||||
|
||||
Resource load(String storageKey) throws IOException;
|
||||
|
||||
void delete(String storageKey) throws IOException;
|
||||
|
||||
/**
|
||||
* Releases any backend-specific resources. Default no-op so {@link LocalStorageProvider} and
|
||||
* {@link DatabaseStorageProvider} (which hold no closeable handles) satisfy Spring's
|
||||
* {@code @Bean(destroyMethod = "close")} signature requirement without ceremony. {@code
|
||||
* S3StorageProvider} overrides this to close the underlying SDK client + presigner.
|
||||
*/
|
||||
@Override
|
||||
default void close() {}
|
||||
|
||||
/**
|
||||
* Returns a presigned download URL valid for {@code ttl}, or {@link Optional#empty()} if the
|
||||
* provider does not support signed URLs (callers fall back to {@link #load(String)}).
|
||||
*/
|
||||
default Optional<URI> signedDownloadUrl(String storageKey, Duration ttl) throws IOException {
|
||||
return signedDownloadUrl(storageKey, ttl, false, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #signedDownloadUrl(String, Duration)} with explicit Content-Disposition control.
|
||||
*/
|
||||
default Optional<URI> signedDownloadUrl(
|
||||
String storageKey, Duration ttl, boolean inline, String originalFilename)
|
||||
throws IOException {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
package stirling.software.proprietary.storage.repository;
|
||||
|
||||
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 stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.storage.model.Folder;
|
||||
|
||||
public interface FolderRepository extends JpaRepository<Folder, UUID> {
|
||||
|
||||
Optional<Folder> findByIdAndOwner(UUID id, User owner);
|
||||
|
||||
List<Folder> findAllByOwnerOrderByName(User owner);
|
||||
|
||||
long countByOwner(User owner);
|
||||
|
||||
/**
|
||||
* Clear the folder reference on every file currently inside any of the given folders. Used when
|
||||
* a folder subtree is deleted - files fall back to the root rather than dangling.
|
||||
*
|
||||
* <p>{@code flushAutomatically + clearAutomatically} forces Hibernate to flush any cached dirty
|
||||
* {@code StoredFile} entities before the bulk UPDATE runs, and clears the persistence context
|
||||
* afterwards so a subsequent {@code deleteAllByIdInBatch} on the parent folders doesn't see
|
||||
* stale entity state referencing the about-to-be-deleted folder.
|
||||
*/
|
||||
@Modifying(flushAutomatically = true, clearAutomatically = true)
|
||||
@Query("UPDATE StoredFile sf SET sf.folder = null WHERE sf.folder.id IN :folderIds")
|
||||
void clearFolderForFiles(@Param("folderIds") List<UUID> folderIds);
|
||||
}
|
||||
-7
@@ -59,13 +59,6 @@ public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
||||
|
||||
List<StoredFile> findAllByOwner(User owner);
|
||||
|
||||
/**
|
||||
* Bulk lookup used by the folder-placement controller. Returns only files owned by {@code
|
||||
* owner}; ids that don't exist or that belong to another user are silently dropped so the
|
||||
* caller can compute the "skipped" set by subtraction.
|
||||
*/
|
||||
List<StoredFile> findAllByIdInAndOwner(List<Long> ids, User owner);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(
|
||||
|
||||
-1
@@ -459,7 +459,6 @@ public class FileStorageService {
|
||||
file.getPurpose() != null
|
||||
? file.getPurpose().name().toLowerCase(Locale.ROOT)
|
||||
: null)
|
||||
.folderId(file.getFolder() != null ? file.getFolder().getId() : null)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
-417
@@ -1,417 +0,0 @@
|
||||
package stirling.software.proprietary.storage.service;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.storage.model.Folder;
|
||||
import stirling.software.proprietary.storage.model.StoredFile;
|
||||
import stirling.software.proprietary.storage.model.api.CreateFolderRequest;
|
||||
import stirling.software.proprietary.storage.model.api.FolderResponse;
|
||||
import stirling.software.proprietary.storage.model.api.UpdateFolderRequest;
|
||||
import stirling.software.proprietary.storage.repository.FolderRepository;
|
||||
import stirling.software.proprietary.storage.repository.StoredFileRepository;
|
||||
|
||||
/**
|
||||
* Phase A folder operations. Each call is scoped to the authenticated user - folders are private to
|
||||
* their owner. Folder-level sharing is a Phase 3 feature.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FolderService {
|
||||
|
||||
/**
|
||||
* Hard cap on folders per user. Beyond this {@link #createFolder} rejects with 409 - guards
|
||||
* against per-account folder-explosion DoS and bounds the in-memory subtree walk in {@link
|
||||
* #deleteFolder}.
|
||||
*/
|
||||
private static final long MAX_FOLDERS_PER_USER = 5_000L;
|
||||
|
||||
/**
|
||||
* Hard cap on chain depth from the root to any folder. Bounds the lazy-proxy walk in {@link
|
||||
* #enforceDepthAndCycle} - otherwise a user could build a chain up to MAX_FOLDERS_PER_USER deep
|
||||
* and force one Hibernate SELECT per ancestor on every reparent (5,000+ SELECTs == seconds of
|
||||
* DB time per request, per-account weaponizable as DoS).
|
||||
*/
|
||||
private static final int MAX_FOLDER_DEPTH = 64;
|
||||
|
||||
/**
|
||||
* Hard cap on bulk-move payload size, mirroring the request-validation cap on {@code
|
||||
* FileFolderPlacementController.BulkMoveRequest.fileIds}. Re-asserted at the service layer
|
||||
* because controller-level @Valid bounds aren't enforced when the service is called directly
|
||||
* (e.g. by future internal callers or tests).
|
||||
*/
|
||||
private static final int BULK_MOVE_MAX_FILES = 1000;
|
||||
|
||||
private final FolderRepository folderRepository;
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Gate every public method on storage being enabled, mirroring {@code
|
||||
* FileStorageService.ensureStorageEnabled}. Without this, folder CRUD still works when {@code
|
||||
* storage.enabled=false} or {@code security.enableLogin=false}, defeating the operator's intent
|
||||
* to disable storage end-to-end.
|
||||
*/
|
||||
private void ensureStorageEnabled() {
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN, "Storage requires login to be enabled");
|
||||
}
|
||||
if (!applicationProperties.getStorage().isEnabled()) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Storage is disabled");
|
||||
}
|
||||
}
|
||||
|
||||
/** List every folder owned by the current user, alphabetical. */
|
||||
@Transactional(readOnly = true)
|
||||
public List<FolderResponse> listFolders() {
|
||||
ensureStorageEnabled();
|
||||
User user = requireAuthenticatedUser();
|
||||
return folderRepository.findAllByOwnerOrderByName(user).stream()
|
||||
.map(FolderResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FolderResponse createFolder(CreateFolderRequest request) {
|
||||
ensureStorageEnabled();
|
||||
User user = requireAuthenticatedUser();
|
||||
// Reject self-parenting up-front. Without this, a client posting
|
||||
// {id: X, parentFolderId: X} for a folder X they already own would silently
|
||||
// get the existing folder back (idempotent path) and never learn that the
|
||||
// parentFolderId they sent was ignored. For new ids the parent lookup would
|
||||
// 404, but the message is misleading.
|
||||
if (request.getId() != null && request.getId().equals(request.getParentFolderId())) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "A folder cannot be its own parent");
|
||||
}
|
||||
Folder parent = resolveParent(request.getParentFolderId(), user, null);
|
||||
|
||||
UUID id = request.getId() != null ? request.getId() : UUID.randomUUID();
|
||||
|
||||
// Idempotent: if this user already owns a folder with the supplied id, return it
|
||||
// unchanged. Single fetch (the previous code did findByIdAndOwner twice with a race
|
||||
// window between the two lookups).
|
||||
java.util.Optional<Folder> existing = folderRepository.findByIdAndOwner(id, user);
|
||||
if (existing.isPresent()) {
|
||||
return FolderResponse.from(existing.get());
|
||||
}
|
||||
|
||||
// The id is a global primary key. If the id exists for a *different* user, surfacing 500
|
||||
// with a constraint-violation stack trace leaks far too much; convert to 409 Conflict so
|
||||
// the caller can pick a fresh id.
|
||||
if (folderRepository.existsById(id)) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"A folder with this id already exists; choose a different id");
|
||||
}
|
||||
|
||||
if (folderRepository.countByOwner(user) >= MAX_FOLDERS_PER_USER) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"Folder limit reached (max " + MAX_FOLDERS_PER_USER + " per user)");
|
||||
}
|
||||
|
||||
Folder folder = new Folder();
|
||||
folder.setId(id);
|
||||
folder.setOwner(user);
|
||||
folder.setParent(parent);
|
||||
folder.setName(request.getName().trim());
|
||||
folder.setColor(request.getColor());
|
||||
folder.setIcon(request.getIcon());
|
||||
|
||||
// saveAndFlush forces the INSERT now so @CreationTimestamp populates
|
||||
// createdAt/updatedAt before we build the response. Plain save defers
|
||||
// the SQL until @Transactional commit, and the response would carry
|
||||
// null timestamps that the frontend trust-boundary parser then rejects.
|
||||
Folder saved = folderRepository.saveAndFlush(folder);
|
||||
log.info(
|
||||
"Folder created: user={} id={} parent={}",
|
||||
user.getId(),
|
||||
saved.getId(),
|
||||
parent == null ? "root" : parent.getId());
|
||||
return FolderResponse.from(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FolderResponse updateFolder(UUID id, UpdateFolderRequest request) {
|
||||
ensureStorageEnabled();
|
||||
User user = requireAuthenticatedUser();
|
||||
Folder folder = requireOwnedFolder(id, user);
|
||||
|
||||
if (request.getName() != null) {
|
||||
String trimmed = request.getName().trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
// Bean validation should already catch this via @Pattern, but be explicit so
|
||||
// an empty-after-trim payload reaches the user as a 400 instead of being
|
||||
// silently dropped.
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Folder name cannot be blank");
|
||||
}
|
||||
folder.setName(trimmed);
|
||||
}
|
||||
|
||||
if (request.shouldReparent()) {
|
||||
Folder newParent = resolveParent(request.getParentFolderId(), user, folder.getId());
|
||||
folder.setParent(newParent);
|
||||
}
|
||||
|
||||
if (request.getColor() != null) {
|
||||
folder.setColor(request.getColor().isEmpty() ? null : request.getColor());
|
||||
}
|
||||
|
||||
if (request.getIcon() != null) {
|
||||
folder.setIcon(request.getIcon().isEmpty() ? null : request.getIcon());
|
||||
}
|
||||
|
||||
// saveAndFlush so @UpdateTimestamp populates updatedAt before the
|
||||
// response is serialized (same reason as createFolder).
|
||||
return FolderResponse.from(folderRepository.saveAndFlush(folder));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive delete. Returns the ids of every folder that was removed so the caller can purge
|
||||
* them from its local cache. Files inside those folders are detached (folder_id set to null) -
|
||||
* never deleted.
|
||||
*/
|
||||
@Transactional
|
||||
public List<UUID> deleteFolder(UUID id) {
|
||||
ensureStorageEnabled();
|
||||
User user = requireAuthenticatedUser();
|
||||
Folder folder = requireOwnedFolder(id, user);
|
||||
|
||||
// Build the parent → children map once. Project to id-only via the
|
||||
// existing entity list (Hibernate already has the column loaded -
|
||||
// we only access f.getParent().getId() on a managed proxy, which
|
||||
// does NOT initialize the proxy because Hibernate has the FK
|
||||
// value cached at the join column).
|
||||
Map<UUID, List<UUID>> childIdsByParent = new HashMap<>();
|
||||
for (Folder f : folderRepository.findAllByOwnerOrderByName(user)) {
|
||||
UUID parentId = f.getParent() == null ? null : f.getParent().getId();
|
||||
childIdsByParent.computeIfAbsent(parentId, k -> new ArrayList<>()).add(f.getId());
|
||||
}
|
||||
|
||||
// Iterative subtree collection - prior recursive form blew the JVM
|
||||
// stack on deeply nested chains a malicious caller could create.
|
||||
List<UUID> removed = new ArrayList<>();
|
||||
Set<UUID> seen = new HashSet<>();
|
||||
Deque<UUID> stack = new ArrayDeque<>();
|
||||
stack.push(folder.getId());
|
||||
while (!stack.isEmpty()) {
|
||||
UUID cur = stack.pop();
|
||||
if (!seen.add(cur)) continue;
|
||||
removed.add(cur);
|
||||
List<UUID> children = childIdsByParent.get(cur);
|
||||
if (children != null) {
|
||||
for (UUID childId : children) stack.push(childId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!removed.isEmpty()) {
|
||||
folderRepository.clearFolderForFiles(removed);
|
||||
folderRepository.deleteAllByIdInBatch(removed);
|
||||
log.info(
|
||||
"Folder subtree deleted: user={} root={} count={}",
|
||||
user.getId(),
|
||||
folder.getId(),
|
||||
removed.size());
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a single owned file to a folder (or root when {@code folderId} is null). Owns its
|
||||
* own @Transactional rather than relying on the caller so the JDBC connection is released as
|
||||
* soon as the writes commit, not held through controller-side JSON serialization.
|
||||
*/
|
||||
@Transactional
|
||||
public void moveFileToFolder(Long fileId, UUID folderId) {
|
||||
ensureStorageEnabled();
|
||||
User user = requireAuthenticatedUser();
|
||||
StoredFile file =
|
||||
storedFileRepository
|
||||
.findByIdAndOwner(fileId, user)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND,
|
||||
"File not found or not owned by current user"));
|
||||
file.setFolder(resolveOwnedFolder(folderId, user));
|
||||
storedFileRepository.save(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk move that returns the moved + skipped split. Skipped == file ids the caller doesn't own
|
||||
* (or that no longer exist); the controller surfaces this as 207 Multi-Status.
|
||||
*/
|
||||
@Transactional
|
||||
public BulkMoveResult bulkMoveFilesToFolder(UUID folderId, List<Long> fileIds) {
|
||||
ensureStorageEnabled();
|
||||
if (fileIds == null || fileIds.isEmpty()) {
|
||||
return new BulkMoveResult(List.of(), List.of());
|
||||
}
|
||||
if (fileIds.size() > BULK_MOVE_MAX_FILES) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"fileIds must contain between 1 and " + BULK_MOVE_MAX_FILES + " entries");
|
||||
}
|
||||
User user = requireAuthenticatedUser();
|
||||
Folder target = resolveOwnedFolder(folderId, user);
|
||||
|
||||
List<StoredFile> owned = storedFileRepository.findAllByIdInAndOwner(fileIds, user);
|
||||
Set<Long> ownedIds = new HashSet<>(owned.size());
|
||||
for (StoredFile f : owned) {
|
||||
f.setFolder(target);
|
||||
ownedIds.add(f.getId());
|
||||
}
|
||||
// If the target folder was deleted concurrently between resolveOwnedFolder and the
|
||||
// flush, the FK constraint fires as DataIntegrityViolationException. Surface that as
|
||||
// 409 Conflict so the caller sees an actionable error instead of a 500 stack.
|
||||
try {
|
||||
storedFileRepository.saveAll(owned);
|
||||
storedFileRepository.flush();
|
||||
} catch (DataIntegrityViolationException ex) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"Target folder no longer exists; refresh and try again",
|
||||
ex);
|
||||
}
|
||||
|
||||
List<Long> moved = owned.stream().map(StoredFile::getId).toList();
|
||||
List<Long> skipped = fileIds.stream().filter(id -> !ownedIds.contains(id)).toList();
|
||||
if (!skipped.isEmpty()) {
|
||||
log.warn(
|
||||
"bulkMove: user {} skipped {} of {} files (not owned or missing)",
|
||||
user.getId(),
|
||||
skipped.size(),
|
||||
fileIds.size());
|
||||
}
|
||||
return new BulkMoveResult(moved, skipped);
|
||||
}
|
||||
|
||||
/** Result of {@link #bulkMoveFilesToFolder}. Records are immutable + auto-serializable. */
|
||||
public record BulkMoveResult(List<Long> movedFileIds, List<Long> skippedFileIds) {}
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a placement-target folder. Distinct from {@link #resolveParent} because move targets
|
||||
* don't carry the parent-cycle semantics - we only need the folder to exist AND belong to the
|
||||
* caller. Returns null for null input (root).
|
||||
*/
|
||||
private Folder resolveOwnedFolder(UUID folderId, User user) {
|
||||
if (folderId == null) return null;
|
||||
return folderRepository
|
||||
.findByIdAndOwner(folderId, user)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Folder does not exist or is not owned by you"));
|
||||
}
|
||||
|
||||
private Folder requireOwnedFolder(UUID id, User user) {
|
||||
return folderRepository
|
||||
.findByIdAndOwner(id, user)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND,
|
||||
"Folder not found or not owned by current user"));
|
||||
}
|
||||
|
||||
private Folder resolveParent(UUID parentId, User user, UUID forbidId) {
|
||||
if (parentId == null) return null;
|
||||
if (forbidId != null && parentId.equals(forbidId)) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "A folder cannot be its own parent");
|
||||
}
|
||||
Folder parent =
|
||||
folderRepository
|
||||
.findByIdAndOwner(parentId, user)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Parent folder does not exist or is not owned by you"));
|
||||
// Reject before the child is created/moved if attaching it would push the chain past the
|
||||
// depth cap. Done in one pass that also returns the cycle answer so we don't walk the
|
||||
// lazy-proxy chain twice.
|
||||
enforceDepthAndCycle(parent, user, forbidId);
|
||||
return parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single pass that walks the parent chain to root and (a) rejects if attaching a child here
|
||||
* would exceed MAX_FOLDER_DEPTH, (b) rejects if {@code forbidId} appears in the chain (cycle on
|
||||
* reparent), (c) rejects on a broken graph, and (d) rejects if any ancestor is owned by a
|
||||
* different user (defense-in-depth: callers always pass a parent already ownership-checked, but
|
||||
* the parent chain is followed via lazy proxy without re-checking ownership at each hop, so any
|
||||
* stray cross-owner edge in the database would otherwise leak ancestor folder ids through the
|
||||
* cycle error message). The walk is hard-bounded at MAX_FOLDER_DEPTH so a corrupted database
|
||||
* (chain longer than the API would allow) can never produce an unbounded SELECT loop.
|
||||
*/
|
||||
private void enforceDepthAndCycle(Folder candidateParent, User user, UUID forbidId) {
|
||||
Folder cursor = candidateParent;
|
||||
Set<UUID> seen = new HashSet<>();
|
||||
int depth = 0;
|
||||
while (cursor != null) {
|
||||
if (cursor.getOwner() == null || !cursor.getOwner().getId().equals(user.getId())) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Folder hierarchy is corrupted; contact support");
|
||||
}
|
||||
if (forbidId != null && cursor.getId().equals(forbidId)) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Cannot move a folder inside one of its descendants");
|
||||
}
|
||||
if (!seen.add(cursor.getId())) {
|
||||
// broken graph (cycle in stored data)
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Folder hierarchy is corrupted; contact support");
|
||||
}
|
||||
depth += 1;
|
||||
// candidateParent is at depth 1 from the new child's perspective. After the walk,
|
||||
// `depth` equals the number of ancestors including candidateParent, which is the
|
||||
// depth at which the new child would live. Reject before exceeding the cap.
|
||||
if (depth >= MAX_FOLDER_DEPTH) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Folder nesting limit reached (max " + MAX_FOLDER_DEPTH + " levels)");
|
||||
}
|
||||
cursor = cursor.getParent();
|
||||
}
|
||||
}
|
||||
|
||||
private User requireAuthenticatedUser() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null
|
||||
|| !authentication.isAuthenticated()
|
||||
|| !(authentication.getPrincipal() instanceof User user)) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package stirling.software.proprietary.cluster;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Runtime license gate contract. Verifies cluster mode is gated by the existing {@code
|
||||
* runningProOrHigher} bean and reports a clear error when the license is missing.
|
||||
*
|
||||
* <p>The gate uses reflection-friendly field injection (one optional bean) so the test wires it
|
||||
* directly without bringing up a full Spring context.
|
||||
*/
|
||||
class ClusterLicenseGateTest {
|
||||
|
||||
private void injectRunningProOrHigher(ClusterLicenseGate gate, Boolean value) throws Exception {
|
||||
Field f = ClusterLicenseGate.class.getDeclaredField("runningProOrHigher");
|
||||
f.setAccessible(true);
|
||||
f.set(gate, value);
|
||||
}
|
||||
|
||||
private void invokeVerify(ClusterLicenseGate gate) throws Throwable {
|
||||
Method m = ClusterLicenseGate.class.getDeclaredMethod("verifyLicense");
|
||||
m.setAccessible(true);
|
||||
try {
|
||||
m.invoke(gate);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void serverOrEnterpriseLicense_allowsClusterMode() throws Throwable {
|
||||
ClusterLicenseGate gate = new ClusterLicenseGate();
|
||||
injectRunningProOrHigher(gate, Boolean.TRUE);
|
||||
assertDoesNotThrow(() -> invokeVerify(gate));
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalLicense_refusesClusterMode_withActionableMessage() throws Exception {
|
||||
ClusterLicenseGate gate = new ClusterLicenseGate();
|
||||
injectRunningProOrHigher(gate, Boolean.FALSE);
|
||||
IllegalStateException ex =
|
||||
assertThrows(IllegalStateException.class, () -> invokeVerify(gate));
|
||||
String msg = ex.getMessage();
|
||||
// The error message must tell the operator exactly what to do.
|
||||
assertTrue(msg.contains("SERVER"), "message must mention SERVER license tier: " + msg);
|
||||
assertTrue(msg.contains("ENTERPRISE"), "message must mention ENTERPRISE tier: " + msg);
|
||||
assertTrue(
|
||||
msg.contains("stirling.premium.key") || msg.contains("license key"),
|
||||
"message must explain how to set the license: " + msg);
|
||||
assertTrue(
|
||||
msg.contains("cluster.enabled=false"),
|
||||
"message must offer the opt-out (disable cluster): " + msg);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saasFlavor_bypassesGate_whenRunningProOrHigherBeanAbsent() throws Throwable {
|
||||
// In saas builds the runningProOrHigher bean is @Profile("security & !saas") so absent.
|
||||
// The gate's @Autowired(required=false) leaves the field null. Must not throw.
|
||||
ClusterLicenseGate gate = new ClusterLicenseGate();
|
||||
// field stays null (default)
|
||||
assertDoesNotThrow(() -> invokeVerify(gate));
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package stirling.software.proprietary.cluster;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import io.micrometer.core.instrument.Gauge;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Contract test for cluster metrics. Asserts every named metric is registered and the recorder
|
||||
* methods write to them, so dashboards do not silently lose a metric to a rename.
|
||||
*/
|
||||
class ClusterMetricsTest {
|
||||
|
||||
private SimpleMeterRegistry registry;
|
||||
private ClusterMetrics metrics;
|
||||
private static final String NODE = "test-node";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
registry = new SimpleMeterRegistry();
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getCluster().getNode().setId(NODE);
|
||||
metrics = new ClusterMetrics(registry, props);
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersAllRequiredMeters() {
|
||||
assertNotNull(registry.find("stirling_cluster_sticky_miss_total").counter());
|
||||
assertNotNull(registry.find("stirling_cluster_ratelimit_rejected_total").counter());
|
||||
assertNotNull(registry.find("stirling_cluster_backplane_latency_seconds").timer());
|
||||
assertNotNull(registry.find("stirling_cluster_job_wait_seconds").timer());
|
||||
Gauge inflight = registry.find("stirling_cluster_jobs_inflight").tag("node", NODE).gauge();
|
||||
assertNotNull(inflight, "jobs_inflight gauge with node tag must be registered eagerly");
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersKnownLaneGaugesEagerly() {
|
||||
// Lanes (FAST, SLOW, AI) are a fixed enum, so all three gauges must exist at construction
|
||||
// - dashboards must never have a missing series for a known lane.
|
||||
for (String lane : new String[] {"FAST", "SLOW", "AI"}) {
|
||||
Gauge g = registry.find("stirling_cluster_queue_depth").tag("lane", lane).gauge();
|
||||
assertNotNull(g, "lane gauge must be eagerly registered for " + lane);
|
||||
assertEquals(0.0, g.value(), "lane gauge default value must be 0 for " + lane);
|
||||
}
|
||||
assertEquals(
|
||||
3,
|
||||
registry.find("stirling_cluster_queue_depth").gauges().size(),
|
||||
"exactly the three known lane gauges should be registered at boot");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordStickyMissIncrementsCounter() {
|
||||
metrics.recordStickyMiss();
|
||||
metrics.recordStickyMiss();
|
||||
assertEquals(2.0, registry.find("stirling_cluster_sticky_miss_total").counter().count());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordRateLimitRejectIncrementsCounter() {
|
||||
metrics.recordRateLimitReject();
|
||||
assertEquals(
|
||||
1.0, registry.find("stirling_cluster_ratelimit_rejected_total").counter().count());
|
||||
}
|
||||
|
||||
@Test
|
||||
void incrementAndDecrementInflightUpdatesGauge() {
|
||||
metrics.incrementInflight();
|
||||
metrics.incrementInflight();
|
||||
metrics.incrementInflight();
|
||||
metrics.decrementInflight();
|
||||
Gauge gauge = registry.find("stirling_cluster_jobs_inflight").tag("node", NODE).gauge();
|
||||
assertEquals(2.0, gauge.value(), "expected 2 inflight after 3 inc / 1 dec");
|
||||
}
|
||||
|
||||
@Test
|
||||
void setQueueDepthUpdatesEagerlyRegisteredLaneGauge() {
|
||||
// The three known-lane gauges (FAST, SLOW, AI) are registered eagerly at construction
|
||||
// (see registersKnownLaneGaugesEagerly); setQueueDepth only updates the holder value.
|
||||
metrics.setQueueDepth("FAST", 4);
|
||||
metrics.setQueueDepth("SLOW", 7);
|
||||
|
||||
Gauge fast = registry.find("stirling_cluster_queue_depth").tag("lane", "FAST").gauge();
|
||||
Gauge slow = registry.find("stirling_cluster_queue_depth").tag("lane", "SLOW").gauge();
|
||||
assertEquals(4.0, fast.value());
|
||||
assertEquals(7.0, slow.value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setQueueDepthForUnknownLane_lazyRegistersFallbackGauge() {
|
||||
// Defensive: if a caller passes an unrecognised lane, we still register so we don't lose
|
||||
// the signal. This is a fallback, not the supported path.
|
||||
metrics.setQueueDepth("custom-lane", 5);
|
||||
Gauge g = registry.find("stirling_cluster_queue_depth").tag("lane", "custom-lane").gauge();
|
||||
assertNotNull(g);
|
||||
assertEquals(5.0, g.value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setQueueDepthIsIdempotentAcrossCalls() {
|
||||
metrics.setQueueDepth("FAST", 1);
|
||||
metrics.setQueueDepth("FAST", 2);
|
||||
metrics.setQueueDepth("FAST", 9);
|
||||
|
||||
// Only one gauge per lane, not three.
|
||||
assertEquals(
|
||||
1,
|
||||
registry.find("stirling_cluster_queue_depth").tag("lane", "FAST").gauges().size());
|
||||
assertEquals(
|
||||
9.0,
|
||||
registry.find("stirling_cluster_queue_depth").tag("lane", "FAST").gauge().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void backplaneLatencyTimerAcceptsRecordings() {
|
||||
metrics.backplaneLatency().record(java.time.Duration.ofMillis(7));
|
||||
metrics.backplaneLatency().record(java.time.Duration.ofMillis(11));
|
||||
assertEquals(
|
||||
2L, registry.find("stirling_cluster_backplane_latency_seconds").timer().count());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jobWaitTimerAcceptsRecordings() {
|
||||
metrics.jobWaitSeconds().record(java.time.Duration.ofMillis(50));
|
||||
assertEquals(1L, registry.find("stirling_cluster_job_wait_seconds").timer().count());
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package stirling.software.proprietary.cluster;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.cluster.ClusterNode;
|
||||
import stirling.software.common.cluster.InstanceRegistry;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/** Verifies the bootstrap registers / heartbeats / deregisters as expected. */
|
||||
class ClusterNodeBootstrapTest {
|
||||
|
||||
private InstanceRegistry registry;
|
||||
private ApplicationProperties props;
|
||||
private ClusterNodeBootstrap bootstrap;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
registry = mock(InstanceRegistry.class);
|
||||
props = new ApplicationProperties();
|
||||
props.getCluster().setEnabled(true);
|
||||
props.getCluster().getNode().setId("node-test-1");
|
||||
props.getCluster().getNode().setRole("worker");
|
||||
// Pin heartbeat to 10s so TTL math is stable across PR2 default changes (TTL = 3x = 30s).
|
||||
props.getCluster().getNode().setHeartbeatIntervalMs(10_000L);
|
||||
bootstrap = new ClusterNodeBootstrap(props, registry);
|
||||
ReflectionTestUtils.setField(bootstrap, "serverPort", 8080);
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerOnStartupCallsRegistryWithResolvedNodeId() {
|
||||
bootstrap.registerOnStartup();
|
||||
ArgumentCaptor<ClusterNode> nodeCaptor = ArgumentCaptor.forClass(ClusterNode.class);
|
||||
ArgumentCaptor<Duration> ttlCaptor = ArgumentCaptor.forClass(Duration.class);
|
||||
verify(registry, times(1)).register(nodeCaptor.capture(), ttlCaptor.capture());
|
||||
ClusterNode captured = nodeCaptor.getValue();
|
||||
assertEquals("node-test-1", captured.nodeId());
|
||||
assertTrue(captured.internalAddress().startsWith("http://"));
|
||||
assertTrue(captured.internalAddress().endsWith(":8080"));
|
||||
assertEquals("WORKER", captured.role());
|
||||
assertEquals(30L, ttlCaptor.getValue().toSeconds());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerHonoursExplicitInternalAddress() {
|
||||
props.getCluster().getNode().setInternalAddress("app-1:8080");
|
||||
bootstrap.registerOnStartup();
|
||||
ArgumentCaptor<ClusterNode> nodeCaptor = ArgumentCaptor.forClass(ClusterNode.class);
|
||||
verify(registry).register(nodeCaptor.capture(), any());
|
||||
assertEquals("http://app-1:8080", nodeCaptor.getValue().internalAddress());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerUsesHttpsSchemeWhenConfigured() {
|
||||
// SE3: nodes that terminate TLS themselves need https:// in the registry so peers can reach
|
||||
// them. The default (http) is correct for the common LB-terminates-TLS topology.
|
||||
props.getCluster().getNode().setInternalAddress("app-1:8443");
|
||||
props.getCluster().getNode().setScheme("https");
|
||||
ClusterNodeBootstrap httpsBootstrap = new ClusterNodeBootstrap(props, registry);
|
||||
ReflectionTestUtils.setField(httpsBootstrap, "serverPort", 8443);
|
||||
httpsBootstrap.registerOnStartup();
|
||||
ArgumentCaptor<ClusterNode> nodeCaptor = ArgumentCaptor.forClass(ClusterNode.class);
|
||||
verify(registry).register(nodeCaptor.capture(), any());
|
||||
assertEquals("https://app-1:8443", nodeCaptor.getValue().internalAddress());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heartbeatAfterStartup_callsRegister_forSelfHealing() {
|
||||
// Heartbeat re-invokes register() (idempotent) so a wiped backplane re-populates
|
||||
// every field, not just lastHeartbeat. Expect 2 register() calls: startup + heartbeat.
|
||||
bootstrap.start();
|
||||
bootstrap.registerOnStartup();
|
||||
bootstrap.heartbeat();
|
||||
verify(registry, times(2))
|
||||
.register(
|
||||
any(ClusterNode.class),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofSeconds(30)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void smartLifecycleStop_deregisters() {
|
||||
bootstrap.start();
|
||||
bootstrap.registerOnStartup();
|
||||
bootstrap.stop();
|
||||
verify(registry, times(1)).deregister("node-test-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void smartLifecycleStop_beforeStartup_isNoop() {
|
||||
bootstrap.stop();
|
||||
verify(registry, never()).deregister(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heartbeatAfterStop_doesNotReRegister() {
|
||||
// Heartbeat-after-stop race: SmartLifecycle.stop() deregisters, but the @Scheduled
|
||||
// tick keeps firing during a slow drain. Without a guard, the next tick would
|
||||
// re-register the dead node and the entry would resurface in the registry until TTL
|
||||
// expiry. Rolling deploys with slow shutdown = draining nodes keep re-announcing
|
||||
// themselves indefinitely.
|
||||
bootstrap.start();
|
||||
bootstrap.registerOnStartup();
|
||||
// 1 register from startup.
|
||||
verify(registry, times(1)).register(any(ClusterNode.class), any(Duration.class));
|
||||
|
||||
bootstrap.stop();
|
||||
verify(registry, times(1)).deregister("node-test-1");
|
||||
|
||||
// Critical: next scheduled tick after stop must NOT re-register.
|
||||
bootstrap.heartbeat();
|
||||
// Still exactly 1 register call (the startup one); no second register from heartbeat.
|
||||
verify(registry, times(1)).register(any(ClusterNode.class), any(Duration.class));
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package stirling.software.proprietary.cluster;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.RedisSerializer;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.session.web.http.CookieSerializer;
|
||||
import org.springframework.session.web.http.DefaultCookieSerializer;
|
||||
|
||||
import jakarta.servlet.http.Cookie;
|
||||
|
||||
/**
|
||||
* Security regression test for {@link ClusterSessionConfiguration}.
|
||||
*
|
||||
* <p>The bean name {@code springSessionDefaultRedisSerializer} is the exact override hook Spring
|
||||
* Session inspects. If it is absent, Spring Session falls back to JDK serialization on session read
|
||||
* / write, which is a deserialization-gadget RCE surface for anyone with Valkey write access.
|
||||
* Asserting both the bean's existence and its concrete type guards against accidental removal
|
||||
* during future refactors.
|
||||
*/
|
||||
class ClusterSessionConfigurationTest {
|
||||
|
||||
@Configuration
|
||||
static class StubConnectionFactoryConfig {
|
||||
@Bean
|
||||
LettuceConnectionFactory lettuceConnectionFactory() {
|
||||
// Stub - satisfies @ConditionalOnBean without opening a real connection.
|
||||
return new LettuceConnectionFactory();
|
||||
}
|
||||
}
|
||||
|
||||
private final ApplicationContextRunner runner =
|
||||
new ApplicationContextRunner()
|
||||
.withUserConfiguration(
|
||||
StubConnectionFactoryConfig.class, ClusterSessionConfiguration.class);
|
||||
|
||||
@Test
|
||||
void clusterDisabled_configurationIsInert_noSerializerBean() {
|
||||
runner.run(
|
||||
context ->
|
||||
assertThat(context)
|
||||
.hasNotFailed()
|
||||
.doesNotHaveBean("springSessionDefaultRedisSerializer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings(
|
||||
"removal") // see ClusterSessionConfiguration#springSessionDefaultRedisSerializer
|
||||
void clusterEnabled_withLettuceFactory_wiresJsonSerializerUnderExpectedBeanName() {
|
||||
runner.withPropertyValues("cluster.enabled=true")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context.containsBean("springSessionDefaultRedisSerializer"))
|
||||
.as("Spring Session looks up this exact bean name")
|
||||
.isTrue();
|
||||
RedisSerializer<?> serializer =
|
||||
context.getBean(
|
||||
"springSessionDefaultRedisSerializer",
|
||||
RedisSerializer.class);
|
||||
assertThat(serializer)
|
||||
.as(
|
||||
"must be JSON serializer; JDK serialization is a"
|
||||
+ " deserialization-gadget RCE surface")
|
||||
.isInstanceOf(GenericJackson2JsonRedisSerializer.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterEnabled_sessionCookie_isSecureHttpOnlyAndSameSiteLax() {
|
||||
runner.withPropertyValues("cluster.enabled=true")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context.containsBean("cookieSerializer")).isTrue();
|
||||
CookieSerializer serializer =
|
||||
context.getBean("cookieSerializer", CookieSerializer.class);
|
||||
assertThat(serializer)
|
||||
.as("must be the Spring Session DefaultCookieSerializer")
|
||||
.isInstanceOf(DefaultCookieSerializer.class);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setSecure(true);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
serializer.writeCookieValue(
|
||||
new CookieSerializer.CookieValue(
|
||||
request, response, "sess-value"));
|
||||
|
||||
Cookie cookie = response.getCookie("SESSION");
|
||||
assertThat(cookie).as("SESSION cookie must be written").isNotNull();
|
||||
assertThat(cookie.getSecure())
|
||||
.as("Secure flag MUST be set for HTTPS deployments")
|
||||
.isTrue();
|
||||
assertThat(cookie.isHttpOnly())
|
||||
.as("HttpOnly MUST be set to block JS access")
|
||||
.isTrue();
|
||||
// SameSite is not a Cookie API field; check the raw Set-Cookie header.
|
||||
assertThat(response.getHeader("Set-Cookie"))
|
||||
.as("SameSite=Lax MUST be present to mitigate CSRF")
|
||||
.contains("SameSite=Lax");
|
||||
});
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package stirling.software.proprietary.cluster;
|
||||
|
||||
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.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.cluster.ClusterBackplane;
|
||||
import stirling.software.common.cluster.JobStore;
|
||||
import stirling.software.common.cluster.JobStoreEntry;
|
||||
import stirling.software.common.cluster.KeyValueCache;
|
||||
import stirling.software.common.cluster.RateLimitStore;
|
||||
import stirling.software.common.cluster.RateLimitStore.RateLimitDecision;
|
||||
import stirling.software.common.cluster.inprocess.InProcessJobStore;
|
||||
import stirling.software.common.cluster.inprocess.InProcessKeyValueCache;
|
||||
import stirling.software.common.cluster.inprocess.InProcessRateLimitStore;
|
||||
|
||||
/**
|
||||
* Multi-node CONTRACT validation in a single JVM. Shares the cluster-visible state (JobStore,
|
||||
* RateLimitStore, KeyValueCache) across two "nodes" - exactly the partition Valkey creates in
|
||||
* production - and asserts cross-node visibility / global counters / cache propagation.
|
||||
*
|
||||
* <p><b>Scope note:</b> this test uses the in-process backplane implementations ({@link
|
||||
* InProcessJobStore}, {@link InProcessKeyValueCache}, {@link InProcessRateLimitStore}), not the
|
||||
* Valkey impls. It verifies the CONTRACT every {@code ClusterBackplane} flavor must honor (shared
|
||||
* map semantics, monotonic counters, evict propagation) and is fast / Docker-free so it runs on
|
||||
* every PR. The Valkey impls share the same contract by construction (single shared Valkey keyspace
|
||||
* = single shared {@code ConcurrentHashMap} from the consumer's POV), so a regression here would
|
||||
* also break the Valkey path.
|
||||
*
|
||||
* <p>For Valkey-specific verification (real Lettuce client, MULTI/EXEC atomicity, TTL expiry, WATCH
|
||||
* race semantics on {@code delete}) see {@code LiveValkeyIntegrationTest}, which spins up a real
|
||||
* Valkey via Testcontainers.
|
||||
*
|
||||
* <p>Result downloads are handled by sticky-session affinity at the load balancer + a {@code 410
|
||||
* Gone} response on the rare miss (verified in {@code JobControllerOwnershipTest}).
|
||||
*/
|
||||
class MultiNodeClusterScenarioTest {
|
||||
|
||||
private JobStore sharedJobStore;
|
||||
private RateLimitStore sharedRateLimit;
|
||||
private KeyValueCache sharedCache;
|
||||
private ClusterBackplane backplaneA;
|
||||
private ClusterBackplane backplaneB;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
sharedJobStore = new InProcessJobStore();
|
||||
sharedRateLimit = new InProcessRateLimitStore();
|
||||
sharedCache = new InProcessKeyValueCache();
|
||||
backplaneA = constBackplane("node-A", "valkey");
|
||||
backplaneB = constBackplane("node-B", "valkey");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("async job created on node-A is readable from node-B via shared JobStore")
|
||||
void jobStatusVisibleCrossNode() {
|
||||
JobStoreEntry entry =
|
||||
new JobStoreEntry(
|
||||
"job-1",
|
||||
JobStoreEntry.JobState.RUNNING,
|
||||
"node-A",
|
||||
Instant.now(),
|
||||
null,
|
||||
null,
|
||||
List.of("file-1"),
|
||||
Map.of());
|
||||
sharedJobStore.put(entry, Duration.ofMinutes(30));
|
||||
|
||||
Optional<JobStoreEntry> seenOnB = sharedJobStore.get("job-1");
|
||||
assertTrue(seenOnB.isPresent(), "node-B must see node-A's job in shared JobStore");
|
||||
assertEquals("node-A", seenOnB.get().owningNodeId());
|
||||
assertEquals(JobStoreEntry.JobState.RUNNING, seenOnB.get().state());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("global rate limit - capacity counted once across both nodes")
|
||||
void rateLimitGlobalAcrossNodes() {
|
||||
long capacity = 4L;
|
||||
RateLimitDecision a1 =
|
||||
sharedRateLimit.tryConsume("user:bob", capacity, Duration.ofMinutes(1));
|
||||
RateLimitDecision b1 =
|
||||
sharedRateLimit.tryConsume("user:bob", capacity, Duration.ofMinutes(1));
|
||||
RateLimitDecision a2 =
|
||||
sharedRateLimit.tryConsume("user:bob", capacity, Duration.ofMinutes(1));
|
||||
RateLimitDecision b2 =
|
||||
sharedRateLimit.tryConsume("user:bob", capacity, Duration.ofMinutes(1));
|
||||
RateLimitDecision a3 =
|
||||
sharedRateLimit.tryConsume("user:bob", capacity, Duration.ofMinutes(1));
|
||||
|
||||
assertTrue(a1.allowed());
|
||||
assertTrue(b1.allowed());
|
||||
assertTrue(a2.allowed());
|
||||
assertTrue(b2.allowed());
|
||||
assertFalse(a3.allowed(), "5th request across both nodes must be rejected (limit=4)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("KeyValueCache populated on A is observed on B; evict on A propagates")
|
||||
void apiKeyCacheVisibleCrossNode() {
|
||||
sharedCache.put("apikey", "hash-bob", "bob", Duration.ofSeconds(60));
|
||||
assertEquals("bob", sharedCache.get("apikey", "hash-bob").orElse(null));
|
||||
sharedCache.evict("apikey", "hash-bob");
|
||||
assertFalse(sharedCache.get("apikey", "hash-bob").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("backplaneType reports 'valkey' on every node; localNodeId is distinct")
|
||||
void backplaneType() {
|
||||
assertEquals("valkey", backplaneA.backplaneType());
|
||||
assertEquals("valkey", backplaneB.backplaneType());
|
||||
assertEquals("node-A", backplaneA.localNodeId());
|
||||
assertEquals("node-B", backplaneB.localNodeId());
|
||||
assertNotEquals(backplaneA.localNodeId(), backplaneB.localNodeId());
|
||||
assertNotNull(backplaneA.localNodeId());
|
||||
}
|
||||
|
||||
private ClusterBackplane constBackplane(String nodeId, String type) {
|
||||
return new ClusterBackplane() {
|
||||
@Override
|
||||
public boolean isHealthy() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String backplaneType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String localNodeId() {
|
||||
return nodeId;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
-147
@@ -1,147 +0,0 @@
|
||||
package stirling.software.proprietary.cluster.s3;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import software.amazon.awssdk.core.checksums.RequestChecksumCalculation;
|
||||
import software.amazon.awssdk.core.checksums.ResponseChecksumValidation;
|
||||
|
||||
class S3ClientsTest {
|
||||
|
||||
@Test
|
||||
void validateEndpointHost_publicAwsHost_passes() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
S3Clients.validateEndpointHost(
|
||||
URI.create("https://s3.us-east-1.amazonaws.com"), false))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateEndpointHost_metadataServiceIp_rejected() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
S3Clients.validateEndpointHost(
|
||||
URI.create("http://169.254.169.254/"), false))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("allow-private-endpoints");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateEndpointHost_loopback_rejected() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
S3Clients.validateEndpointHost(
|
||||
URI.create("http://127.0.0.1:9000/"), false))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateEndpointHost_rfc1918Private_rejected() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
S3Clients.validateEndpointHost(
|
||||
URI.create("http://10.0.0.5:9000/"), false))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateEndpointHost_allowPrivateOptIn_bypassesCheck() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
S3Clients.validateEndpointHost(
|
||||
URI.create("http://169.254.169.254/"), true))
|
||||
.doesNotThrowAnyException();
|
||||
assertThatCode(
|
||||
() ->
|
||||
S3Clients.validateEndpointHost(
|
||||
URI.create("http://127.0.0.1:9000/"), true))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateEndpointHost_missingHost_rejected() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
S3Clients.validateEndpointHost(
|
||||
URI.create("file:///etc/passwd"), false))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("must include a host");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateEndpointHost_errorMessageNamesTheFlag() {
|
||||
assertThat(
|
||||
catchMessage(
|
||||
() ->
|
||||
S3Clients.validateEndpointHost(
|
||||
URI.create("http://192.168.1.10:9000/"), false)))
|
||||
.contains("storage.s3.allow-private-endpoints");
|
||||
}
|
||||
|
||||
// ----- requestChecksumCalculation parsing -----
|
||||
|
||||
@Test
|
||||
void parseRequestChecksum_nullOrBlank_defaultsToWhenSupported() {
|
||||
assertThat(S3Clients.parseRequestChecksum(null))
|
||||
.isEqualTo(RequestChecksumCalculation.WHEN_SUPPORTED);
|
||||
assertThat(S3Clients.parseRequestChecksum(""))
|
||||
.isEqualTo(RequestChecksumCalculation.WHEN_SUPPORTED);
|
||||
assertThat(S3Clients.parseRequestChecksum(" "))
|
||||
.isEqualTo(RequestChecksumCalculation.WHEN_SUPPORTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseRequestChecksum_caseInsensitive_andTrimmed() {
|
||||
assertThat(S3Clients.parseRequestChecksum("when_required"))
|
||||
.isEqualTo(RequestChecksumCalculation.WHEN_REQUIRED);
|
||||
assertThat(S3Clients.parseRequestChecksum(" WHEN_REQUIRED "))
|
||||
.isEqualTo(RequestChecksumCalculation.WHEN_REQUIRED);
|
||||
assertThat(S3Clients.parseRequestChecksum("When_Supported"))
|
||||
.isEqualTo(RequestChecksumCalculation.WHEN_SUPPORTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseRequestChecksum_unknownValue_fallsBackToDefault() {
|
||||
assertThat(S3Clients.parseRequestChecksum("yes-please"))
|
||||
.isEqualTo(RequestChecksumCalculation.WHEN_SUPPORTED);
|
||||
assertThat(S3Clients.parseRequestChecksum("disabled-completely"))
|
||||
.isEqualTo(RequestChecksumCalculation.WHEN_SUPPORTED);
|
||||
}
|
||||
|
||||
// ----- responseChecksumValidation parsing -----
|
||||
|
||||
@Test
|
||||
void parseResponseChecksum_nullOrBlank_defaultsToWhenSupported() {
|
||||
assertThat(S3Clients.parseResponseChecksum(null))
|
||||
.isEqualTo(ResponseChecksumValidation.WHEN_SUPPORTED);
|
||||
assertThat(S3Clients.parseResponseChecksum(""))
|
||||
.isEqualTo(ResponseChecksumValidation.WHEN_SUPPORTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseResponseChecksum_explicitWhenRequired_returnedAsEnum() {
|
||||
assertThat(S3Clients.parseResponseChecksum("WHEN_REQUIRED"))
|
||||
.isEqualTo(ResponseChecksumValidation.WHEN_REQUIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseResponseChecksum_unknownValue_fallsBackToDefault() {
|
||||
assertThat(S3Clients.parseResponseChecksum("nope"))
|
||||
.isEqualTo(ResponseChecksumValidation.WHEN_SUPPORTED);
|
||||
}
|
||||
|
||||
private static String catchMessage(Runnable r) {
|
||||
try {
|
||||
r.run();
|
||||
return "";
|
||||
} catch (RuntimeException e) {
|
||||
return e.getMessage() == null ? "" : e.getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
-253
@@ -1,253 +0,0 @@
|
||||
package stirling.software.proprietary.cluster.s3;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.MinIOContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import stirling.software.common.cluster.FileStore;
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
|
||||
@Testcontainers(disabledWithoutDocker = true)
|
||||
class S3FileStoreTest {
|
||||
|
||||
private static final String BUCKET = "stirling-test-filestore";
|
||||
private static final String ACCESS_KEY = "minioadmin";
|
||||
private static final String SECRET_KEY = "minioadmin";
|
||||
|
||||
@Container
|
||||
static MinIOContainer minio =
|
||||
new MinIOContainer("minio/minio:latest")
|
||||
.withUserName(ACCESS_KEY)
|
||||
.withPassword(SECRET_KEY);
|
||||
|
||||
private static S3Client s3Client;
|
||||
private static S3FileStore store;
|
||||
|
||||
@BeforeAll
|
||||
static void setUp() {
|
||||
URI endpoint = URI.create(minio.getS3URL());
|
||||
AwsBasicCredentials creds = AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY);
|
||||
S3Configuration s3Config = S3Configuration.builder().pathStyleAccessEnabled(true).build();
|
||||
|
||||
s3Client =
|
||||
S3Client.builder()
|
||||
.endpointOverride(endpoint)
|
||||
.httpClient(UrlConnectionHttpClient.create())
|
||||
.region(Region.US_EAST_1)
|
||||
.credentialsProvider(StaticCredentialsProvider.create(creds))
|
||||
.serviceConfiguration(s3Config)
|
||||
.build();
|
||||
|
||||
s3Client.createBucket(CreateBucketRequest.builder().bucket(BUCKET).build());
|
||||
store = new S3FileStore(s3Client, BUCKET, "transient/", false);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDown() {
|
||||
if (store != null) {
|
||||
store.close();
|
||||
}
|
||||
if (s3Client != null) {
|
||||
s3Client.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankBucket_constructorRejects() {
|
||||
assertThatThrownBy(() -> new S3FileStore(s3Client, ""))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new S3FileStore(s3Client, null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void store_thenRetrieve_roundTripsContent() throws IOException {
|
||||
byte[] payload = "hello cluster s3".getBytes(StandardCharsets.UTF_8);
|
||||
FileStore.Stored stored = store.store(new ByteArrayInputStream(payload), "foo.txt");
|
||||
|
||||
assertThat(stored.fileId()).isNotBlank();
|
||||
assertThat(stored.size()).isEqualTo(payload.length);
|
||||
|
||||
assertThat(store.exists(stored.fileId())).isTrue();
|
||||
assertThat(store.size(stored.fileId())).isEqualTo(payload.length);
|
||||
assertThat(store.retrieveBytes(stored.fileId())).isEqualTo(payload);
|
||||
|
||||
try (InputStream in = store.retrieve(stored.fileId())) {
|
||||
assertThat(in.readAllBytes()).isEqualTo(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void store_keysUseConfiguredPrefix() throws IOException {
|
||||
byte[] payload = "prefixed".getBytes(StandardCharsets.UTF_8);
|
||||
FileStore.Stored stored = store.store(new ByteArrayInputStream(payload), "p.txt");
|
||||
|
||||
String prefixed = store.resolveKey(stored.fileId());
|
||||
assertThat(prefixed).startsWith("transient/");
|
||||
s3Client.headObject(HeadObjectRequest.builder().bucket(BUCKET).key(prefixed).build());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
s3Client.headObject(
|
||||
HeadObjectRequest.builder()
|
||||
.bucket(BUCKET)
|
||||
.key(stored.fileId())
|
||||
.build()))
|
||||
.isInstanceOfAny(
|
||||
NoSuchKeyException.class,
|
||||
software.amazon.awssdk.services.s3.model.S3Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyPrefix_writesAtBucketRoot() throws IOException {
|
||||
S3FileStore rootStore = new S3FileStore(s3Client, BUCKET, "", false);
|
||||
byte[] payload = "no-prefix".getBytes(StandardCharsets.UTF_8);
|
||||
FileStore.Stored stored = rootStore.store(new ByteArrayInputStream(payload), "r.txt");
|
||||
assertThat(rootStore.resolveKey(stored.fileId())).isEqualTo(stored.fileId());
|
||||
assertThat(rootStore.retrieveBytes(stored.fileId())).isEqualTo(payload);
|
||||
assertThat(rootStore.delete(stored.fileId())).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_removesObject_andReturnsTrue() throws IOException {
|
||||
FileStore.Stored stored =
|
||||
store.store(new ByteArrayInputStream(new byte[] {1, 2, 3}), "d.bin");
|
||||
assertThat(store.delete(stored.fileId())).isTrue();
|
||||
assertThat(store.exists(stored.fileId())).isFalse();
|
||||
assertThatThrownBy(() -> store.retrieveBytes(stored.fileId()))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_unknownKey_isIdempotentReturnsTrue() {
|
||||
// S3 DeleteObject is idempotent (returns 204 whether or not the object existed).
|
||||
// The store reflects S3's behaviour rather than racing a HEAD before each DELETE.
|
||||
assertThat(store.delete("00000000-0000-0000-0000-000000000000")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void retrieve_missingKey_throwsIOException() {
|
||||
assertThatThrownBy(() -> store.retrieveBytes("does-not-exist"))
|
||||
.isInstanceOf(IOException.class);
|
||||
assertThatThrownBy(() -> store.retrieve("does-not-exist")).isInstanceOf(IOException.class);
|
||||
assertThatThrownBy(() -> store.size("does-not-exist")).isInstanceOf(IOException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exists_returnsFalseForBlankOrTraversalIds() {
|
||||
assertThat(store.exists(null)).isFalse();
|
||||
assertThat(store.exists("")).isFalse();
|
||||
assertThat(store.exists("..")).isFalse();
|
||||
assertThat(store.exists("a/b")).isFalse();
|
||||
assertThat(store.exists("a\\b")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_traversalId_returnsFalseWithoutCall() {
|
||||
assertThat(store.delete("../etc/passwd")).isFalse();
|
||||
assertThat(store.delete("foo/bar")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void store_largePayload_streamsViaTempFileWithoutBufferingInMemory() throws IOException {
|
||||
long payloadSize = 16L * 1024 * 1024;
|
||||
Path tempDir = Path.of(System.getProperty("java.io.tmpdir"));
|
||||
long uploadTempsBefore = countS3UploadTemps(tempDir);
|
||||
|
||||
FileStore.Stored stored;
|
||||
try (InputStream large = new RepeatingInputStream((byte) 0x42, payloadSize)) {
|
||||
stored = store.store(large, "big.bin");
|
||||
}
|
||||
|
||||
assertThat(stored.size()).isEqualTo(payloadSize);
|
||||
assertThat(store.size(stored.fileId())).isEqualTo(payloadSize);
|
||||
assertThat(countS3UploadTemps(tempDir)).isEqualTo(uploadTempsBefore);
|
||||
store.delete(stored.fileId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void store_uploadFailure_stillDeletesTempFile() {
|
||||
Path tempDir = Path.of(System.getProperty("java.io.tmpdir"));
|
||||
long uploadTempsBefore = countS3UploadTemps(tempDir);
|
||||
|
||||
// Non-existent bucket causes putObject to fail after the temp file is written, exercising
|
||||
// the failure-path cleanup in the finally block.
|
||||
S3FileStore brokenStore =
|
||||
new S3FileStore(s3Client, "bucket-that-does-not-exist", "transient/", false);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
brokenStore.store(
|
||||
new ByteArrayInputStream(
|
||||
"payload".getBytes(StandardCharsets.UTF_8)),
|
||||
"x.bin"))
|
||||
.isInstanceOf(IOException.class);
|
||||
|
||||
assertThat(countS3UploadTemps(tempDir)).isEqualTo(uploadTempsBefore);
|
||||
}
|
||||
|
||||
private static long countS3UploadTemps(Path tempDir) {
|
||||
try (Stream<Path> entries = Files.list(tempDir)) {
|
||||
return entries.filter(p -> p.getFileName().toString().startsWith("s3-upload-")).count();
|
||||
} catch (IOException e) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/** Generates {@code length} bytes of a single value without buffering them in memory. */
|
||||
private static final class RepeatingInputStream extends InputStream {
|
||||
private final byte value;
|
||||
private long remaining;
|
||||
|
||||
RepeatingInputStream(byte value, long length) {
|
||||
this.value = value;
|
||||
this.remaining = length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() {
|
||||
if (remaining <= 0) {
|
||||
return -1;
|
||||
}
|
||||
remaining--;
|
||||
return value & 0xFF;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) {
|
||||
if (remaining <= 0) {
|
||||
return -1;
|
||||
}
|
||||
int toWrite = (int) Math.min(len, remaining);
|
||||
for (int i = 0; i < toWrite; i++) {
|
||||
b[off + i] = value;
|
||||
}
|
||||
remaining -= toWrite;
|
||||
return toWrite;
|
||||
}
|
||||
}
|
||||
}
|
||||
-779
@@ -1,779 +0,0 @@
|
||||
package stirling.software.proprietary.cluster.s3;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.common.cluster.FileStore;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.storage.provider.S3StorageProvider;
|
||||
import stirling.software.proprietary.storage.provider.StoredObject;
|
||||
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
|
||||
|
||||
/**
|
||||
* Comprehensive live-vendor test against a real S3-compatible endpoint specified via {@code
|
||||
* S3_SMOKE_*} env vars. Skipped automatically when {@code S3_SMOKE_ENDPOINT} is not set, so CI is
|
||||
* not affected. Covers:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code S3StorageProvider} CRUD: store / load / delete / presigned URL
|
||||
* <li>{@code S3FileStore} CRUD (cluster artifact path)
|
||||
* <li>Folder semantics simulated via key prefixes (matches production usage)
|
||||
* <li>Negative paths: wrong secret, missing bucket, missing key, traversal IDs
|
||||
* <li>Edge cases: zero-byte, unicode filename, multi-megabyte streaming
|
||||
* <li>Configuration guards: SSRF endpoint rejection, bucket validation
|
||||
* </ul>
|
||||
*
|
||||
* Every uploaded key is tracked and removed in {@link #cleanUp} so re-running against the same
|
||||
* bucket leaves no residue.
|
||||
*/
|
||||
@EnabledIfEnvironmentVariable(named = "S3_SMOKE_ENDPOINT", matches = ".+")
|
||||
class S3VendorComprehensiveTest {
|
||||
|
||||
private static final String PREFIX = "stirling-comprehensive/" + UUID.randomUUID() + "/";
|
||||
|
||||
private static ApplicationProperties.Storage.S3 cfg;
|
||||
private static S3Clients.Bundle bundle;
|
||||
private static S3StorageProvider provider;
|
||||
private static String bucket;
|
||||
private static String vendorLabel;
|
||||
private static User owner;
|
||||
|
||||
private static final List<String> keysToCleanup =
|
||||
Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
@BeforeAll
|
||||
static void setUp() {
|
||||
cfg = configFromEnv();
|
||||
bucket = cfg.getBucket();
|
||||
vendorLabel = System.getenv().getOrDefault("S3_SMOKE_LABEL", "external");
|
||||
bundle = S3Clients.build(cfg, "comprehensive[" + vendorLabel + "]");
|
||||
provider = new S3StorageProvider(bundle.client(), bundle.presigner(), bucket);
|
||||
|
||||
owner = new User();
|
||||
owner.setId(7L);
|
||||
owner.setUsername("comprehensive-tester");
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void cleanUp() {
|
||||
if (bundle != null) {
|
||||
for (String key : keysToCleanup) {
|
||||
try {
|
||||
bundle.client().deleteObject(d -> d.bucket(bucket).key(key));
|
||||
} catch (Exception e) {
|
||||
// Best-effort cleanup; ignore.
|
||||
}
|
||||
}
|
||||
try {
|
||||
provider.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
bundle.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static String track(String key) {
|
||||
keysToCleanup.add(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
private static ApplicationProperties.Storage.S3 configFromEnv() {
|
||||
ApplicationProperties.Storage.S3 c = new ApplicationProperties.Storage.S3();
|
||||
c.setEndpoint(System.getenv("S3_SMOKE_ENDPOINT"));
|
||||
c.setBucket(requireEnv("S3_SMOKE_BUCKET"));
|
||||
c.setRegion(System.getenv().getOrDefault("S3_SMOKE_REGION", "us-east-1"));
|
||||
c.setAccessKey(requireEnv("S3_SMOKE_KEY"));
|
||||
c.setSecretKey(requireEnv("S3_SMOKE_SECRET"));
|
||||
c.setPathStyleAccess(
|
||||
Boolean.parseBoolean(System.getenv().getOrDefault("S3_SMOKE_PATHSTYLE", "false")));
|
||||
c.setAllowPrivateEndpoints(false);
|
||||
return c;
|
||||
}
|
||||
|
||||
private static String requireEnv(String name) {
|
||||
String value = System.getenv(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalStateException(name + " env var must be set");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ==========================================================================================
|
||||
// FILE CRUD via S3StorageProvider (user-uploaded files)
|
||||
// ==========================================================================================
|
||||
|
||||
@Test
|
||||
void provider_store_thenLoad_matchesBytes() throws IOException {
|
||||
byte[] payload = ("provider-roundtrip-" + vendorLabel).getBytes(StandardCharsets.UTF_8);
|
||||
MockMultipartFile file = new MockMultipartFile("file", "doc.txt", "text/plain", payload);
|
||||
|
||||
StoredObject obj = provider.store(owner, file);
|
||||
track(obj.getStorageKey());
|
||||
|
||||
assertThat(obj.getStorageKey()).isNotBlank();
|
||||
assertThat(obj.getSizeBytes()).isEqualTo(payload.length);
|
||||
assertThat(provider.load(obj.getStorageKey()).getInputStream().readAllBytes())
|
||||
.isEqualTo(payload);
|
||||
}
|
||||
|
||||
@Test
|
||||
void provider_delete_removesObject() throws IOException {
|
||||
byte[] payload = "delete-me".getBytes(StandardCharsets.UTF_8);
|
||||
MockMultipartFile file = new MockMultipartFile("file", "x.txt", "text/plain", payload);
|
||||
StoredObject obj = provider.store(owner, file);
|
||||
track(obj.getStorageKey());
|
||||
|
||||
provider.delete(obj.getStorageKey());
|
||||
|
||||
assertThatThrownBy(() -> provider.load(obj.getStorageKey()))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void provider_load_missingKey_throws() {
|
||||
assertThatThrownBy(() -> provider.load(PREFIX + "does-not-exist"))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void provider_presignedDownload_returnsBytesOverHttp() throws Exception {
|
||||
byte[] payload = "presign me".getBytes(StandardCharsets.UTF_8);
|
||||
MockMultipartFile file = new MockMultipartFile("file", "p.txt", "text/plain", payload);
|
||||
StoredObject obj = provider.store(owner, file);
|
||||
track(obj.getStorageKey());
|
||||
|
||||
java.util.Optional<java.net.URI> url =
|
||||
provider.signedDownloadUrl(obj.getStorageKey(), Duration.ofMinutes(5));
|
||||
assertThat(url).isPresent();
|
||||
|
||||
HttpResponse<byte[]> resp =
|
||||
HttpClient.newHttpClient()
|
||||
.send(
|
||||
HttpRequest.newBuilder(url.get()).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
|
||||
assertThat(resp.statusCode()).isEqualTo(200);
|
||||
assertThat(resp.body()).isEqualTo(payload);
|
||||
}
|
||||
|
||||
@Test
|
||||
void provider_store_zeroBytes_isAccepted() throws IOException {
|
||||
MockMultipartFile empty =
|
||||
new MockMultipartFile("file", "empty.txt", "text/plain", new byte[0]);
|
||||
StoredObject obj = provider.store(owner, empty);
|
||||
track(obj.getStorageKey());
|
||||
|
||||
assertThat(obj.getSizeBytes()).isZero();
|
||||
assertThat(provider.load(obj.getStorageKey()).getInputStream().readAllBytes())
|
||||
.isEqualTo(new byte[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void provider_store_unicodeFilename_yieldsOpaqueAsciiKey_andPreservesNameForDisplay()
|
||||
throws IOException {
|
||||
// Regression: pre-fix, the storage key embedded the filename verbatim, which Supabase
|
||||
// rejected with 400 Invalid key. Post-fix, the key is {ownerId}/{uuid} (ASCII-only)
|
||||
// and the original unicode name lives on StoredObject.originalFilename.
|
||||
String unicodeName = "résumé-日本語-é.pdf";
|
||||
byte[] payload = "u".getBytes(StandardCharsets.UTF_8);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("file", unicodeName, "application/pdf", payload);
|
||||
|
||||
StoredObject obj = provider.store(owner, file);
|
||||
track(obj.getStorageKey());
|
||||
|
||||
assertThat(obj.getStorageKey()).matches("[0-9]+/[0-9a-fA-F-]+");
|
||||
assertThat(obj.getStorageKey())
|
||||
.isEqualTo(
|
||||
new String(
|
||||
obj.getStorageKey().getBytes(StandardCharsets.US_ASCII),
|
||||
StandardCharsets.US_ASCII));
|
||||
assertThat(obj.getOriginalFilename()).isEqualTo(unicodeName);
|
||||
assertThat(provider.load(obj.getStorageKey()).getInputStream().readAllBytes())
|
||||
.isEqualTo(payload);
|
||||
}
|
||||
|
||||
// ==========================================================================================
|
||||
// Concurrency, overwrite, TTL expiry (added after initial run surfaced the unicode bug)
|
||||
// ==========================================================================================
|
||||
|
||||
@Test
|
||||
void provider_concurrent10Uploads_allSucceedWithDistinctKeys() throws Exception {
|
||||
int n = 10;
|
||||
java.util.concurrent.ExecutorService pool =
|
||||
java.util.concurrent.Executors.newFixedThreadPool(n);
|
||||
try {
|
||||
List<java.util.concurrent.Future<StoredObject>> futures = new ArrayList<>();
|
||||
for (int i = 0; i < n; i++) {
|
||||
final int idx = i;
|
||||
futures.add(
|
||||
pool.submit(
|
||||
() -> {
|
||||
byte[] payload =
|
||||
("concurrent-" + idx).getBytes(StandardCharsets.UTF_8);
|
||||
MockMultipartFile f =
|
||||
new MockMultipartFile(
|
||||
"file",
|
||||
"c-" + idx + ".txt",
|
||||
"text/plain",
|
||||
payload);
|
||||
StoredObject obj = provider.store(owner, f);
|
||||
track(obj.getStorageKey());
|
||||
return obj;
|
||||
}));
|
||||
}
|
||||
|
||||
java.util.Set<String> keys = new java.util.HashSet<>();
|
||||
for (java.util.concurrent.Future<StoredObject> fut : futures) {
|
||||
StoredObject obj = fut.get(30, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertThat(keys.add(obj.getStorageKey()))
|
||||
.as("distinct key for each parallel upload")
|
||||
.isTrue();
|
||||
assertThat(provider.load(obj.getStorageKey()).getInputStream().readAllBytes())
|
||||
.isNotEmpty();
|
||||
}
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameKey_overwrite_returnsLatestPayload() {
|
||||
String key = PREFIX + "overwrite-" + UUID.randomUUID() + ".txt";
|
||||
track(key);
|
||||
|
||||
byte[] first = "FIRST".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] second = "SECOND".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
bundle.client().putObject(p -> p.bucket(bucket).key(key), RequestBody.fromBytes(first));
|
||||
bundle.client().putObject(p -> p.bucket(bucket).key(key), RequestBody.fromBytes(second));
|
||||
|
||||
assertThat(getRaw(key)).isEqualTo(second);
|
||||
}
|
||||
|
||||
@Test
|
||||
void presignedDownload_afterTtlExpiry_returns403() throws Exception {
|
||||
String key = PREFIX + "presign-expiry-" + UUID.randomUUID() + ".txt";
|
||||
byte[] payload = "presign expiry".getBytes(StandardCharsets.UTF_8);
|
||||
track(putRaw(key, "presign expiry"));
|
||||
|
||||
// 2-second TTL, then wait long enough that any vendor clock skew tolerance is also past.
|
||||
PresignedGetObjectRequest presigned =
|
||||
bundle.presigner()
|
||||
.presignGetObject(
|
||||
GetObjectPresignRequest.builder()
|
||||
.signatureDuration(Duration.ofSeconds(2))
|
||||
.getObjectRequest(g -> g.bucket(bucket).key(key))
|
||||
.build());
|
||||
|
||||
// Confirm it works while valid - rules out unrelated failures.
|
||||
HttpResponse<byte[]> ok =
|
||||
HttpClient.newHttpClient()
|
||||
.send(
|
||||
HttpRequest.newBuilder(presigned.url().toURI()).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
assertThat(ok.statusCode()).isEqualTo(200);
|
||||
assertThat(ok.body()).isEqualTo(payload);
|
||||
|
||||
Thread.sleep(5_000);
|
||||
|
||||
HttpResponse<byte[]> expired =
|
||||
HttpClient.newHttpClient()
|
||||
.send(
|
||||
HttpRequest.newBuilder(presigned.url().toURI()).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
assertThat(expired.statusCode())
|
||||
.as("presigned URL must be rejected after TTL expires")
|
||||
.isIn(400, 403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void provider_store_4MBPayload_streams() throws IOException {
|
||||
byte[] payload = new byte[4 * 1024 * 1024];
|
||||
java.util.Arrays.fill(payload, (byte) 0x42);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("file", "big.bin", "application/octet-stream", payload);
|
||||
|
||||
StoredObject obj = provider.store(owner, file);
|
||||
track(obj.getStorageKey());
|
||||
|
||||
assertThat(obj.getSizeBytes()).isEqualTo(payload.length);
|
||||
assertThat(provider.load(obj.getStorageKey()).getInputStream().readAllBytes())
|
||||
.isEqualTo(payload);
|
||||
}
|
||||
|
||||
// ==========================================================================================
|
||||
// FILE CRUD via S3FileStore (cluster artifact path)
|
||||
// ==========================================================================================
|
||||
|
||||
@Test
|
||||
void fileStore_storeAndRetrieve_roundTrip() throws IOException {
|
||||
S3FileStore store = new S3FileStore(bundle.client(), bucket, PREFIX + "fs/", false);
|
||||
byte[] payload = "filestore round trip".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
FileStore.Stored stored = store.store(new ByteArrayInputStream(payload), "rt.txt");
|
||||
track(store.resolveKey(stored.fileId()));
|
||||
|
||||
assertThat(store.size(stored.fileId())).isEqualTo(payload.length);
|
||||
assertThat(store.retrieveBytes(stored.fileId())).isEqualTo(payload);
|
||||
assertThat(store.exists(stored.fileId())).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fileStore_delete_returnsTrue_andExistsFalseAfter() throws IOException {
|
||||
S3FileStore store = new S3FileStore(bundle.client(), bucket, PREFIX + "fs/", false);
|
||||
FileStore.Stored stored = store.store(new ByteArrayInputStream("x".getBytes()), "del.txt");
|
||||
|
||||
assertThat(store.delete(stored.fileId())).isTrue();
|
||||
assertThat(store.exists(stored.fileId())).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fileStore_retrieveBytes_missingKey_throws() {
|
||||
S3FileStore store = new S3FileStore(bundle.client(), bucket, PREFIX + "fs/", false);
|
||||
assertThatThrownBy(() -> store.retrieveBytes("does-not-exist"))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fileStore_rejectsTraversalId() {
|
||||
S3FileStore store = new S3FileStore(bundle.client(), bucket, PREFIX + "fs/", false);
|
||||
assertThat(store.exists("..")).isFalse();
|
||||
assertThat(store.delete("../etc/passwd")).isFalse();
|
||||
assertThat(store.exists("a/b")).isFalse();
|
||||
assertThat(store.exists("a\\b")).isFalse();
|
||||
}
|
||||
|
||||
// ==========================================================================================
|
||||
// Folder semantics simulated via key prefixes
|
||||
// ==========================================================================================
|
||||
|
||||
@Test
|
||||
void folderPrefix_isolatesObjects_andDeleteByPrefixDoesNotTouchRoot() throws IOException {
|
||||
// Two "folders" + a root object - all reuse the test PREFIX so cleanup catches them.
|
||||
String folderA = PREFIX + "folder-A/";
|
||||
String folderB = PREFIX + "folder-B/";
|
||||
String rootObj = PREFIX + "root-" + UUID.randomUUID() + ".txt";
|
||||
|
||||
track(putRaw(folderA + "file-1.txt", "in-A"));
|
||||
track(putRaw(folderA + "file-2.txt", "in-A2"));
|
||||
track(putRaw(folderB + "file-1.txt", "in-B"));
|
||||
track(putRaw(rootObj, "at-root"));
|
||||
|
||||
// "Delete folder A": delete every key under folderA prefix
|
||||
deleteAllUnderPrefix(folderA);
|
||||
|
||||
// Verify A is empty, B and root untouched
|
||||
assertThat(headOrNull(folderA + "file-1.txt")).isNull();
|
||||
assertThat(headOrNull(folderB + "file-1.txt")).isNotNull();
|
||||
assertThat(headOrNull(rootObj)).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void moveBetweenFolders_viaCopyAndDelete_preservesContent() throws Exception {
|
||||
String oldKey = PREFIX + "move-old/" + UUID.randomUUID() + ".txt";
|
||||
String newKey = PREFIX + "move-new/" + UUID.randomUUID() + ".txt";
|
||||
byte[] payload = "moveable".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
track(oldKey);
|
||||
track(newKey);
|
||||
bundle.client()
|
||||
.putObject(p -> p.bucket(bucket).key(oldKey), RequestBody.fromBytes(payload));
|
||||
|
||||
// Simulate move: server-side copy + delete original.
|
||||
bundle.client()
|
||||
.copyObject(
|
||||
c ->
|
||||
c.sourceBucket(bucket)
|
||||
.sourceKey(oldKey)
|
||||
.destinationBucket(bucket)
|
||||
.destinationKey(newKey));
|
||||
bundle.client().deleteObject(d -> d.bucket(bucket).key(oldKey));
|
||||
|
||||
assertThat(headOrNull(oldKey)).isNull();
|
||||
assertThat(getRaw(newKey)).isEqualTo(payload);
|
||||
}
|
||||
|
||||
// ==========================================================================================
|
||||
// Negative: wrong settings / wrong creds
|
||||
// ==========================================================================================
|
||||
|
||||
@Test
|
||||
void wrongSecret_throwsOnFirstOperation() {
|
||||
ApplicationProperties.Storage.S3 bad = configFromEnv();
|
||||
bad.setSecretKey("definitely-not-the-real-secret-" + UUID.randomUUID());
|
||||
|
||||
try (S3Clients.Bundle badBundle = S3Clients.build(bad, "wrong-secret")) {
|
||||
assertThatThrownBy(() -> badBundle.client().headBucket(h -> h.bucket(bucket)))
|
||||
.isInstanceOf(S3Exception.class)
|
||||
.satisfies(e -> assertThat(((S3Exception) e).statusCode()).isIn(401, 403, 400));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonExistentBucket_throwsOnHeadOrPut() {
|
||||
String fakeBucket = "stirling-no-such-bucket-" + UUID.randomUUID();
|
||||
assertThatThrownBy(() -> bundle.client().headBucket(h -> h.bucket(fakeBucket)))
|
||||
.isInstanceOfAny(NoSuchBucketException.class, S3Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankBucket_atBuildTime_throwsIllegalState() {
|
||||
ApplicationProperties.Storage.S3 bad = configFromEnv();
|
||||
bad.setBucket("");
|
||||
assertThatThrownBy(() -> S3Clients.build(bad, "blank-bucket"))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("bucket");
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidEndpointUri_atBuildTime_throwsIllegalState() {
|
||||
ApplicationProperties.Storage.S3 bad = configFromEnv();
|
||||
bad.setEndpoint("not a valid uri ::::");
|
||||
assertThatThrownBy(() -> S3Clients.build(bad, "bad-uri"))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void privateEndpoint_withoutOptIn_atBuildTime_throwsIllegalState() {
|
||||
ApplicationProperties.Storage.S3 bad = configFromEnv();
|
||||
bad.setEndpoint("http://127.0.0.1:9000");
|
||||
bad.setAllowPrivateEndpoints(false);
|
||||
assertThatThrownBy(() -> S3Clients.build(bad, "loopback"))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("private");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getMissingKey_returnsNoSuchKey() {
|
||||
String missing = PREFIX + "missing-" + UUID.randomUUID();
|
||||
assertThatThrownBy(() -> bundle.client().getObject(g -> g.bucket(bucket).key(missing)))
|
||||
.isInstanceOfAny(NoSuchKeyException.class, S3Exception.class);
|
||||
}
|
||||
|
||||
// ==========================================================================================
|
||||
// Bundle lifecycle
|
||||
// ==========================================================================================
|
||||
|
||||
@Test
|
||||
void bundleClose_isIdempotent() {
|
||||
ApplicationProperties.Storage.S3 c = configFromEnv();
|
||||
S3Clients.Bundle b = S3Clients.build(c, "lifecycle");
|
||||
b.close();
|
||||
b.close(); // should not throw
|
||||
}
|
||||
|
||||
// ==========================================================================================
|
||||
// Internal helpers (using the bundle directly for prefix/folder simulation)
|
||||
// ==========================================================================================
|
||||
|
||||
private String putRaw(String key, String body) {
|
||||
bundle.client()
|
||||
.putObject(
|
||||
p -> p.bucket(bucket).key(key),
|
||||
RequestBody.fromBytes(body.getBytes(StandardCharsets.UTF_8)));
|
||||
return key;
|
||||
}
|
||||
|
||||
private byte[] getRaw(String key) {
|
||||
return bundle.client().getObjectAsBytes(g -> g.bucket(bucket).key(key)).asByteArray();
|
||||
}
|
||||
|
||||
private Object headOrNull(String key) {
|
||||
try {
|
||||
return bundle.client().headObject(h -> h.bucket(bucket).key(key));
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] tryGetBytes(String key) {
|
||||
try {
|
||||
return bundle.client().getObjectAsBytes(g -> g.bucket(bucket).key(key)).asByteArray();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteAllUnderPrefix(String prefix) {
|
||||
var listing = bundle.client().listObjectsV2(l -> l.bucket(bucket).prefix(prefix));
|
||||
for (var obj : listing.contents()) {
|
||||
bundle.client().deleteObject(d -> d.bucket(bucket).key(obj.key()));
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================================
|
||||
// Key edge cases: leading/trailing/double slash, length, URL-special chars
|
||||
// ==========================================================================================
|
||||
|
||||
@Test
|
||||
void key_trailingSlash_storesAsZeroByteFolderMarker() {
|
||||
String key = PREFIX + "folder-marker-" + UUID.randomUUID() + "/";
|
||||
track(key);
|
||||
|
||||
// S3 spec: trailing slash is legal and creates a 0-byte "folder marker" object.
|
||||
// Some vendors normalize it away; capture either behavior.
|
||||
bundle.client()
|
||||
.putObject(p -> p.bucket(bucket).key(key), RequestBody.fromBytes(new byte[0]));
|
||||
Object head = headOrNull(key);
|
||||
// Either: vendor accepts the marker (head is non-null) or normalizes to bare key.
|
||||
assertThat(head != null || headOrNull(key.substring(0, key.length() - 1)) != null)
|
||||
.as("vendor should either accept trailing-slash marker or normalize to bare key")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void key_doubleSlash_normalizedOrStoredVerbatim() {
|
||||
String key = PREFIX + "double//slash-" + UUID.randomUUID() + ".txt";
|
||||
track(key);
|
||||
bundle.client()
|
||||
.putObject(
|
||||
p -> p.bucket(bucket).key(key),
|
||||
RequestBody.fromBytes("ds".getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
// Either GET-with-the-exact-key works, or vendor normalized -> single-slash form works.
|
||||
String alt = key.replace("//", "/");
|
||||
track(alt);
|
||||
byte[] viaExact = tryGetBytes(key);
|
||||
byte[] viaNormalized = tryGetBytes(alt);
|
||||
assertThat(viaExact != null || viaNormalized != null)
|
||||
.as("either exact double-slash key or normalized single-slash form must return")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void key_200Chars_isStoredAndRetrievable() {
|
||||
// Stirling production keys are ~45 chars ({ownerId}/{uuid}). 200 chars exceeds that by
|
||||
// ~5x but stays inside every vendor's documented limit. The S3 spec max is 1024 bytes
|
||||
// but some vendors (Supabase) impose stricter caps (~250-byte total path including
|
||||
// bucket prefix - 1000 chars fails with KeyTooLongError).
|
||||
StringBuilder sb = new StringBuilder(PREFIX + "long/");
|
||||
while (sb.length() < 200) {
|
||||
sb.append("abcdefghij");
|
||||
}
|
||||
String longKey = sb.substring(0, 200);
|
||||
track(longKey);
|
||||
|
||||
byte[] payload = "long-key".getBytes(StandardCharsets.UTF_8);
|
||||
bundle.client()
|
||||
.putObject(p -> p.bucket(bucket).key(longKey), RequestBody.fromBytes(payload));
|
||||
assertThat(getRaw(longKey)).isEqualTo(payload);
|
||||
}
|
||||
|
||||
@Test
|
||||
void key_safeSpecialChars_areSignedAndRetrievableViaSdk() {
|
||||
// Restrict to chars every S3-compatible vendor accepts: dot, dash, underscore.
|
||||
// Stirling's production key format ({ownerId}/{uuid}) is even narrower; this test
|
||||
// confirms the SDK SigV4 signer copes with slightly more exotic ASCII-safe keys.
|
||||
// Note: Supabase rejects keys containing space / + / ? / & / # ("400 Invalid key"),
|
||||
// see documentsVendorKeyRestrictions_tolerantTest for that documentation.
|
||||
String key =
|
||||
PREFIX
|
||||
+ "safe-special/"
|
||||
+ UUID.randomUUID()
|
||||
+ "_segment.with-dots.and_underscores.txt";
|
||||
track(key);
|
||||
|
||||
bundle.client()
|
||||
.putObject(
|
||||
p -> p.bucket(bucket).key(key),
|
||||
RequestBody.fromBytes("safe".getBytes(StandardCharsets.UTF_8)));
|
||||
assertThat(getRaw(key)).isEqualTo("safe".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentsVendorKeyRestrictions_tolerantTest() {
|
||||
// Documents - rather than enforces - which key characters cause vendor rejection.
|
||||
// Stirling production code is safe because S3StorageProvider always emits an
|
||||
// ASCII-safe UUID-only key. If you ever change that, this test becomes a canary.
|
||||
// AWS S3 and MinIO accept all of these; Supabase rejects all of them with 400.
|
||||
String[] suspiciousKeys = {
|
||||
PREFIX + "with space.txt",
|
||||
PREFIX + "with+plus.txt",
|
||||
PREFIX + "with#hash.txt",
|
||||
PREFIX + "with?question.txt",
|
||||
PREFIX + "with&.txt",
|
||||
};
|
||||
int accepted = 0;
|
||||
int rejected = 0;
|
||||
for (String k : suspiciousKeys) {
|
||||
track(k);
|
||||
try {
|
||||
bundle.client()
|
||||
.putObject(
|
||||
p -> p.bucket(bucket).key(k),
|
||||
RequestBody.fromBytes("x".getBytes(StandardCharsets.UTF_8)));
|
||||
accepted++;
|
||||
} catch (S3Exception e) {
|
||||
assertThat(e.statusCode())
|
||||
.as("vendor rejection must be a clean 4xx, not a signature mismatch")
|
||||
.isBetween(400, 499);
|
||||
rejected++;
|
||||
}
|
||||
}
|
||||
assertThat(accepted + rejected).isEqualTo(suspiciousKeys.length);
|
||||
}
|
||||
|
||||
// ==========================================================================================
|
||||
// Presigned-URL: TTL bounds + Content-Disposition behavior (Stirling uses this for shares)
|
||||
// ==========================================================================================
|
||||
|
||||
@Test
|
||||
void presignedGet_ttlExceeding7Days_isRejectedAtSigningTime() {
|
||||
String key = PREFIX + "ttl-overflow-" + UUID.randomUUID() + ".txt";
|
||||
track(putRaw(key, "x"));
|
||||
|
||||
// SigV4 caps presigned URL TTL at 7 days. SDK should refuse to sign anything larger.
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
bundle.presigner()
|
||||
.presignGetObject(
|
||||
GetObjectPresignRequest.builder()
|
||||
.signatureDuration(Duration.ofDays(8))
|
||||
.getObjectRequest(
|
||||
g -> g.bucket(bucket).key(key))
|
||||
.build()))
|
||||
.isInstanceOfAny(IllegalArgumentException.class, RuntimeException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void provider_signedDownloadUrl_attachmentDisposition_endsWithAttachmentHeader()
|
||||
throws Exception {
|
||||
byte[] payload = "attach me".getBytes(StandardCharsets.UTF_8);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("file", "report.pdf", "application/pdf", payload);
|
||||
StoredObject obj = provider.store(owner, file);
|
||||
track(obj.getStorageKey());
|
||||
|
||||
java.util.Optional<java.net.URI> url =
|
||||
provider.signedDownloadUrl(
|
||||
obj.getStorageKey(), Duration.ofMinutes(2), false, "report.pdf");
|
||||
assertThat(url).isPresent();
|
||||
|
||||
HttpResponse<byte[]> resp =
|
||||
HttpClient.newHttpClient()
|
||||
.send(
|
||||
HttpRequest.newBuilder(url.get()).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
|
||||
assertThat(resp.statusCode()).isEqualTo(200);
|
||||
// Supabase + AWS both honor response-content-disposition query param.
|
||||
assertThat(resp.headers().firstValue("content-disposition").orElse(""))
|
||||
.as("vendor must honor response-content-disposition override in presigned URL")
|
||||
.startsWith("attachment");
|
||||
}
|
||||
|
||||
@Test
|
||||
void provider_signedDownloadUrl_inlineDisposition_endsWithInlineHeader() throws Exception {
|
||||
byte[] payload = "inline".getBytes(StandardCharsets.UTF_8);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("file", "preview.pdf", "application/pdf", payload);
|
||||
StoredObject obj = provider.store(owner, file);
|
||||
track(obj.getStorageKey());
|
||||
|
||||
java.util.Optional<java.net.URI> url =
|
||||
provider.signedDownloadUrl(
|
||||
obj.getStorageKey(), Duration.ofMinutes(2), true, "preview.pdf");
|
||||
assertThat(url).isPresent();
|
||||
|
||||
HttpResponse<byte[]> resp =
|
||||
HttpClient.newHttpClient()
|
||||
.send(
|
||||
HttpRequest.newBuilder(url.get()).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
|
||||
assertThat(resp.statusCode()).isEqualTo(200);
|
||||
assertThat(resp.headers().firstValue("content-disposition").orElse(""))
|
||||
.as("inline=true must set 'inline' disposition")
|
||||
.startsWith("inline");
|
||||
}
|
||||
|
||||
// ==========================================================================================
|
||||
// List pagination + HEAD missing semantics
|
||||
// ==========================================================================================
|
||||
|
||||
@Test
|
||||
void listObjectsV2_paginationWithMaxKeys_returnsContinuationToken() {
|
||||
// Stage 3 objects under a unique sub-prefix.
|
||||
String prefix = PREFIX + "page-" + UUID.randomUUID() + "/";
|
||||
for (int i = 0; i < 3; i++) {
|
||||
track(putRaw(prefix + "obj-" + i, "p" + i));
|
||||
}
|
||||
|
||||
var first = bundle.client().listObjectsV2(l -> l.bucket(bucket).prefix(prefix).maxKeys(1));
|
||||
assertThat(first.contents()).hasSize(1);
|
||||
assertThat(first.isTruncated()).isTrue();
|
||||
assertThat(first.nextContinuationToken()).isNotBlank();
|
||||
|
||||
var second =
|
||||
bundle.client()
|
||||
.listObjectsV2(
|
||||
l ->
|
||||
l.bucket(bucket)
|
||||
.prefix(prefix)
|
||||
.maxKeys(2)
|
||||
.continuationToken(first.nextContinuationToken()));
|
||||
assertThat(second.contents()).hasSize(2);
|
||||
assertThat(second.isTruncated()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void headObject_missingKey_throwsNoSuchKeyOr404() {
|
||||
String missing = PREFIX + "head-missing-" + UUID.randomUUID();
|
||||
assertThatThrownBy(() -> bundle.client().headObject(h -> h.bucket(bucket).key(missing)))
|
||||
.isInstanceOf(S3Exception.class)
|
||||
.satisfies(e -> assertThat(((S3Exception) e).statusCode()).isEqualTo(404));
|
||||
}
|
||||
|
||||
/**
|
||||
* Presigned-URL test scaffolding for parity with the smoke test (covers the SDK presign path).
|
||||
*/
|
||||
@Test
|
||||
void presignGetObject_independentOfProvider_returnsBytes() throws Exception {
|
||||
String key = PREFIX + "presign-direct-" + UUID.randomUUID() + ".txt";
|
||||
byte[] payload = "direct presign".getBytes(StandardCharsets.UTF_8);
|
||||
track(putRaw(key, "direct presign"));
|
||||
|
||||
PresignedGetObjectRequest presigned =
|
||||
bundle.presigner()
|
||||
.presignGetObject(
|
||||
GetObjectPresignRequest.builder()
|
||||
.signatureDuration(Duration.ofMinutes(2))
|
||||
.getObjectRequest(g -> g.bucket(bucket).key(key))
|
||||
.build());
|
||||
|
||||
HttpResponse<byte[]> resp =
|
||||
HttpClient.newHttpClient()
|
||||
.send(
|
||||
HttpRequest.newBuilder(presigned.url().toURI()).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
|
||||
assertThat(resp.statusCode()).isEqualTo(200);
|
||||
assertThat(resp.body()).isEqualTo(payload);
|
||||
}
|
||||
}
|
||||
-161
@@ -1,161 +0,0 @@
|
||||
package stirling.software.proprietary.cluster.s3;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.localstack.LocalStackContainer;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
import stirling.software.common.cluster.FileStore;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
|
||||
|
||||
/**
|
||||
* End-to-end smoke against the full {@link S3Clients#build} path. Defaults to a LocalStack
|
||||
* container so it runs in CI; if {@code S3_SMOKE_ENDPOINT} is set, swaps in a real vendor (AWS /
|
||||
* Supabase / R2 / MinIO over network) to validate live signing + DNS.
|
||||
*/
|
||||
@Testcontainers(disabledWithoutDocker = true)
|
||||
class S3VendorSmokeTest {
|
||||
|
||||
private static LocalStackContainer localstack;
|
||||
private static S3Clients.Bundle bundle;
|
||||
private static String bucket;
|
||||
private static String vendorLabel;
|
||||
|
||||
@BeforeAll
|
||||
static void setUp() {
|
||||
ApplicationProperties.Storage.S3 cfg = new ApplicationProperties.Storage.S3();
|
||||
String envEndpoint = System.getenv("S3_SMOKE_ENDPOINT");
|
||||
|
||||
if (envEndpoint != null && !envEndpoint.isBlank()) {
|
||||
vendorLabel = System.getenv().getOrDefault("S3_SMOKE_LABEL", "external");
|
||||
cfg.setEndpoint(envEndpoint);
|
||||
cfg.setBucket(requireEnv("S3_SMOKE_BUCKET"));
|
||||
cfg.setRegion(System.getenv().getOrDefault("S3_SMOKE_REGION", "us-east-1"));
|
||||
cfg.setAccessKey(requireEnv("S3_SMOKE_KEY"));
|
||||
cfg.setSecretKey(requireEnv("S3_SMOKE_SECRET"));
|
||||
cfg.setPathStyleAccess(
|
||||
Boolean.parseBoolean(
|
||||
System.getenv().getOrDefault("S3_SMOKE_PATHSTYLE", "false")));
|
||||
cfg.setAllowPrivateEndpoints(
|
||||
Boolean.parseBoolean(
|
||||
System.getenv().getOrDefault("S3_SMOKE_ALLOWPRIVATE", "false")));
|
||||
} else {
|
||||
vendorLabel = "localstack";
|
||||
localstack =
|
||||
new LocalStackContainer(DockerImageName.parse("localstack/localstack:3.8"))
|
||||
.withServices(LocalStackContainer.Service.S3);
|
||||
localstack.start();
|
||||
cfg.setEndpoint(
|
||||
localstack.getEndpointOverride(LocalStackContainer.Service.S3).toString());
|
||||
cfg.setBucket("stirling-smoke");
|
||||
cfg.setRegion(localstack.getRegion());
|
||||
cfg.setAccessKey(localstack.getAccessKey());
|
||||
cfg.setSecretKey(localstack.getSecretKey());
|
||||
// Exercise virtual-hosted addressing where possible. LocalStack supports both;
|
||||
// path-style remains covered by the MinIO suite.
|
||||
cfg.setPathStyleAccess(false);
|
||||
// Required: localhost is a loopback address and would otherwise be rejected.
|
||||
cfg.setAllowPrivateEndpoints(true);
|
||||
}
|
||||
|
||||
bundle = S3Clients.build(cfg, "vendor-smoke[" + vendorLabel + "]");
|
||||
bucket = cfg.getBucket();
|
||||
ensureBucketExists(bucket);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDown() {
|
||||
if (bundle != null) {
|
||||
bundle.close();
|
||||
}
|
||||
if (localstack != null) {
|
||||
localstack.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void s3FileStore_roundTripsContentAgainstVendor() throws Exception {
|
||||
S3FileStore store = new S3FileStore(bundle.client(), bucket, "smoke/", false);
|
||||
byte[] payload = ("hello from " + vendorLabel).getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
FileStore.Stored stored =
|
||||
store.store(new ByteArrayInputStream(payload), "smoke-payload.txt");
|
||||
try {
|
||||
assertThat(stored.size()).isEqualTo(payload.length);
|
||||
assertThat(store.exists(stored.fileId())).isTrue();
|
||||
assertThat(store.size(stored.fileId())).isEqualTo(payload.length);
|
||||
assertThat(store.retrieveBytes(stored.fileId())).isEqualTo(payload);
|
||||
} finally {
|
||||
assertThat(store.delete(stored.fileId())).isTrue();
|
||||
assertThat(store.exists(stored.fileId())).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void presignedGet_downloadsContentOverHttp() throws Exception {
|
||||
String key = "smoke/presign-" + System.currentTimeMillis() + ".txt";
|
||||
byte[] payload = ("presigned by " + vendorLabel).getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
bundle.client().putObject(p -> p.bucket(bucket).key(key), RequestBody.fromBytes(payload));
|
||||
try {
|
||||
PresignedGetObjectRequest presigned =
|
||||
bundle.presigner()
|
||||
.presignGetObject(
|
||||
GetObjectPresignRequest.builder()
|
||||
.signatureDuration(Duration.ofMinutes(5))
|
||||
.getObjectRequest(g -> g.bucket(bucket).key(key))
|
||||
.build());
|
||||
|
||||
HttpResponse<byte[]> resp =
|
||||
HttpClient.newHttpClient()
|
||||
.send(
|
||||
HttpRequest.newBuilder(presigned.url().toURI()).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
|
||||
assertThat(resp.statusCode()).isEqualTo(200);
|
||||
assertThat(resp.body()).isEqualTo(payload);
|
||||
} finally {
|
||||
bundle.client().deleteObject(d -> d.bucket(bucket).key(key));
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireEnv(String name) {
|
||||
String value = System.getenv(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
name + " env var must be set when S3_SMOKE_ENDPOINT is set");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void ensureBucketExists(String b) {
|
||||
try {
|
||||
bundle.client().headBucket(h -> h.bucket(b));
|
||||
} catch (S3Exception e) {
|
||||
if (e.statusCode() == 404 || e.statusCode() == 301 || e.statusCode() == 400) {
|
||||
try {
|
||||
bundle.client().createBucket(c -> c.bucket(b));
|
||||
} catch (S3Exception ignored) {
|
||||
// Bucket already exists or vendor disallows runtime create (Supabase/R2 often
|
||||
// require pre-create). Caller is expected to have pre-created it in that case.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+502
@@ -0,0 +1,502 @@
|
||||
package stirling.software.proprietary.cluster.valkey;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIf;
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.testcontainers.DockerClientFactory;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
import stirling.software.common.cluster.ClusterNode;
|
||||
import stirling.software.common.cluster.DistributedLock;
|
||||
import stirling.software.common.cluster.JobStoreEntry;
|
||||
import stirling.software.common.cluster.RateLimitStore.RateLimitDecision;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Live integration tests against a real Valkey instance, started by Testcontainers. The
|
||||
* {@code @EnabledIf} guard probes the Docker daemon via {@link
|
||||
* DockerClientFactory#isDockerAvailable()} (non-throwing) so the suite skips cleanly when Docker is
|
||||
* unavailable - without that guard, {@code @Testcontainers} would throw {@code initializationError}
|
||||
* (test FAILURE, not skip) on CI runners without Docker.
|
||||
*/
|
||||
@Testcontainers
|
||||
@EnabledIf("isDockerAvailable")
|
||||
class LiveValkeyIntegrationTest {
|
||||
|
||||
@Container
|
||||
static final GenericContainer<?> VALKEY =
|
||||
new GenericContainer<>(DockerImageName.parse("valkey/valkey:8.0-alpine"))
|
||||
.withExposedPorts(6379);
|
||||
|
||||
static boolean isDockerAvailable() {
|
||||
return DockerClientFactory.instance().isDockerAvailable();
|
||||
}
|
||||
|
||||
private static LettuceConnectionFactory factoryA;
|
||||
private static LettuceConnectionFactory factoryB;
|
||||
private static StringRedisTemplate templateA;
|
||||
private static StringRedisTemplate templateB;
|
||||
|
||||
@BeforeAll
|
||||
static void connect() {
|
||||
String host = VALKEY.getHost();
|
||||
int port = VALKEY.getMappedPort(6379);
|
||||
factoryA = new LettuceConnectionFactory(new RedisStandaloneConfiguration(host, port));
|
||||
factoryA.afterPropertiesSet();
|
||||
factoryB = new LettuceConnectionFactory(new RedisStandaloneConfiguration(host, port));
|
||||
factoryB.afterPropertiesSet();
|
||||
templateA = new StringRedisTemplate(factoryA);
|
||||
templateB = new StringRedisTemplate(factoryB);
|
||||
// Flush so each run starts clean (test-only)
|
||||
templateA.getConnectionFactory().getConnection().serverCommands().flushAll();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void disconnect() {
|
||||
if (factoryA != null) factoryA.destroy();
|
||||
if (factoryB != null) factoryB.destroy();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Valkey reachable and isHealthy() = true after PING round-trip")
|
||||
void backplaneHealthy() {
|
||||
ApplicationProperties propsA = newProps("node-A");
|
||||
ValkeyClusterBackplane bp = new ValkeyClusterBackplane(propsA, templateA);
|
||||
assertEquals("valkey", bp.backplaneType());
|
||||
assertEquals("node-A", bp.localNodeId());
|
||||
assertTrue(bp.isHealthy(), "Valkey must be reachable in the Testcontainers instance");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("JobStore put on connection A, get on connection B reads the same entry")
|
||||
void jobStoreCrossConnectionVisibility() {
|
||||
ValkeyJobStore storeA = new ValkeyJobStore(templateA);
|
||||
ValkeyJobStore storeB = new ValkeyJobStore(templateB);
|
||||
|
||||
JobStoreEntry entry =
|
||||
new JobStoreEntry(
|
||||
"live-job-1",
|
||||
JobStoreEntry.JobState.RUNNING,
|
||||
"node-A",
|
||||
Instant.now(),
|
||||
null,
|
||||
null,
|
||||
List.of("live-file-1"),
|
||||
Map.of("k", "v"));
|
||||
storeA.put(entry, Duration.ofSeconds(30));
|
||||
|
||||
Optional<JobStoreEntry> seen = storeB.get("live-job-1");
|
||||
assertTrue(seen.isPresent(), "storeB on different connection must see storeA's write");
|
||||
assertEquals("node-A", seen.get().owningNodeId());
|
||||
assertEquals(JobStoreEntry.JobState.RUNNING, seen.get().state());
|
||||
|
||||
// Reverse file→job index
|
||||
assertEquals("live-job-1", storeB.findJobIdByFileId("live-file-1").orElse(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("JobStore entry expires after the configured duration")
|
||||
void jobStoreTtlExpires() throws InterruptedException {
|
||||
ValkeyJobStore store = new ValkeyJobStore(templateA);
|
||||
store.put(
|
||||
new JobStoreEntry(
|
||||
"ttl-job",
|
||||
JobStoreEntry.JobState.PENDING,
|
||||
"node-A",
|
||||
Instant.now(),
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
Map.of()),
|
||||
Duration.ofSeconds(2));
|
||||
assertTrue(store.exists("ttl-job"));
|
||||
// Valkey expiry is lazy / sample-based so a 500 ms margin can race; use ~1 s.
|
||||
// Poll for up to 3 s so we don't double the suite's wall-clock when Valkey is timely.
|
||||
long deadline = System.currentTimeMillis() + 3000;
|
||||
boolean expired = false;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (!store.exists("ttl-job")) {
|
||||
expired = true;
|
||||
break;
|
||||
}
|
||||
Thread.sleep(100);
|
||||
}
|
||||
assertTrue(expired, "entry should TTL-expire within 3 s of a 2 s TTL");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("KeyValueCache propagates across connections; evict observed cross-connection")
|
||||
void keyValueCacheCrossConnection() {
|
||||
ValkeyKeyValueCache cacheA = new ValkeyKeyValueCache(templateA);
|
||||
ValkeyKeyValueCache cacheB = new ValkeyKeyValueCache(templateB);
|
||||
|
||||
cacheA.put("apikey", "hash-bob", "bob", Duration.ofSeconds(30));
|
||||
assertEquals("bob", cacheB.get("apikey", "hash-bob").orElse(null));
|
||||
|
||||
cacheA.evict("apikey", "hash-bob");
|
||||
assertFalse(cacheB.get("apikey", "hash-bob").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RateLimitStore enforces ONE global budget across two instances")
|
||||
void rateLimitGlobalAcrossInstances() {
|
||||
ValkeyRateLimitStore storeA = newRateLimitStore(factoryA);
|
||||
ValkeyRateLimitStore storeB = newRateLimitStore(factoryB);
|
||||
String key = "live-user:alice";
|
||||
long capacity = 4;
|
||||
|
||||
AtomicInteger allowed = new AtomicInteger();
|
||||
for (int i = 0; i < 8; i++) {
|
||||
// alternate consumers
|
||||
var store = (i % 2 == 0) ? storeA : storeB;
|
||||
RateLimitDecision d = store.tryConsume(key, capacity, Duration.ofSeconds(30));
|
||||
if (d.allowed()) allowed.incrementAndGet();
|
||||
}
|
||||
assertEquals(
|
||||
4,
|
||||
allowed.get(),
|
||||
"exactly 4 (the global capacity) must be allowed across both instances");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DistributedLock excludes a second acquirer on a different connection")
|
||||
void distributedLockMutualExclusion() {
|
||||
ValkeyDistributedLock lockA = new ValkeyDistributedLock(templateA);
|
||||
ValkeyDistributedLock lockB = new ValkeyDistributedLock(templateB);
|
||||
|
||||
Optional<DistributedLock.LockHandle> heldByA =
|
||||
lockA.tryAcquire("election-X", Duration.ofSeconds(30));
|
||||
assertTrue(heldByA.isPresent());
|
||||
|
||||
Optional<DistributedLock.LockHandle> heldByB =
|
||||
lockB.tryAcquire("election-X", Duration.ofSeconds(30));
|
||||
assertFalse(heldByB.isPresent(), "second acquirer must fail while A holds the lock");
|
||||
|
||||
heldByA.get().release();
|
||||
|
||||
// After release, B can acquire
|
||||
Optional<DistributedLock.LockHandle> retry =
|
||||
lockB.tryAcquire("election-X", Duration.ofSeconds(30));
|
||||
assertTrue(retry.isPresent());
|
||||
retry.get().release();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("register is atomic (hash + TTL committed together, no orphan keys on crash)")
|
||||
void registryRegisterIsAtomic() {
|
||||
ValkeyInstanceRegistry reg = new ValkeyInstanceRegistry(templateA);
|
||||
ClusterNode node =
|
||||
new ClusterNode(
|
||||
"atomic-node-" + java.util.UUID.randomUUID(),
|
||||
"10.0.0.99:8080",
|
||||
Instant.now(),
|
||||
"BOTH");
|
||||
reg.register(node, Duration.ofSeconds(30));
|
||||
|
||||
// After register returns, the key must have a positive TTL. A TTL of -1 (no expiry)
|
||||
// would mean the EXPIRE didn't ride along inside the MULTI/EXEC and the entry would
|
||||
// persist forever past node death.
|
||||
Long ttlMs =
|
||||
templateA.getExpire(
|
||||
"stirling:nodes:" + node.nodeId(),
|
||||
java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
assertNotNull(ttlMs);
|
||||
assertTrue(
|
||||
ttlMs > 0 && ttlMs <= 30_000,
|
||||
"register() must atomically arm TTL; expected (0, 30000] ms, got " + ttlMs);
|
||||
|
||||
// Sanity: the hash fields are present too (atomic commit, both sides observable).
|
||||
Optional<ClusterNode> seen = reg.lookup(node.nodeId());
|
||||
assertTrue(seen.isPresent(), "hash fields must be visible after atomic register()");
|
||||
assertEquals("10.0.0.99:8080", seen.get().internalAddress());
|
||||
|
||||
reg.deregister(node.nodeId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("register on connection A is visible from connection B")
|
||||
void registryCrossConnection() {
|
||||
ValkeyInstanceRegistry regA = new ValkeyInstanceRegistry(templateA);
|
||||
ValkeyInstanceRegistry regB = new ValkeyInstanceRegistry(templateB);
|
||||
|
||||
ClusterNode node = new ClusterNode("live-node-7", "10.0.0.7:8080", Instant.now(), "BOTH");
|
||||
regA.register(node, Duration.ofSeconds(30));
|
||||
|
||||
Optional<ClusterNode> seen = regB.lookup("live-node-7");
|
||||
assertTrue(seen.isPresent());
|
||||
assertEquals("10.0.0.7:8080", seen.get().internalAddress());
|
||||
|
||||
boolean inActive =
|
||||
regB.activeNodes().stream().anyMatch(n -> "live-node-7".equals(n.nodeId()));
|
||||
assertTrue(inActive);
|
||||
|
||||
regA.deregister("live-node-7");
|
||||
assertFalse(regB.lookup("live-node-7").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Bucket4j: no fixed-window boundary doubling (parity with in-process semantics)")
|
||||
void rateLimitNoBoundaryDoubling() throws InterruptedException {
|
||||
// The old Lua INCR+EXPIRE allowed 2x capacity at window boundaries: empty bucket at end
|
||||
// of window N, full bucket at start of window N+1, observable as 2*capacity within the
|
||||
// boundary. Token-bucket greedy refill smooths this so total over a short boundary window
|
||||
// never exceeds capacity + at most one full refill share.
|
||||
ValkeyRateLimitStore store = newRateLimitStore(factoryA);
|
||||
String key = "boundary-" + java.util.UUID.randomUUID();
|
||||
long capacity = 5;
|
||||
Duration window = Duration.ofMillis(500);
|
||||
|
||||
// Drain the bucket in window N.
|
||||
int firstAllowed = 0;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (store.tryConsume(key, capacity, window).allowed()) firstAllowed++;
|
||||
}
|
||||
assertEquals(capacity, firstAllowed, "must allow exactly capacity tokens initially");
|
||||
|
||||
// Wait just past the window. Under fixed-window we'd see another full capacity allowed
|
||||
// immediately (boundary doubling). Under token-bucket greedy refill we get roughly the
|
||||
// capacity-per-window rate, not a full burst again.
|
||||
Thread.sleep(window.toMillis() + 50);
|
||||
int secondAllowed = 0;
|
||||
long start = System.nanoTime();
|
||||
for (int i = 0; i < 20 && (System.nanoTime() - start) < 20_000_000L; i++) {
|
||||
if (store.tryConsume(key, capacity, window).allowed()) secondAllowed++;
|
||||
}
|
||||
// Allow slack but assert we cannot drain a *second* full capacity instantly.
|
||||
assertTrue(
|
||||
secondAllowed <= capacity,
|
||||
"token-bucket must not let a fresh full capacity be consumed instantly across"
|
||||
+ " the boundary; got "
|
||||
+ secondAllowed);
|
||||
}
|
||||
|
||||
private ValkeyRateLimitStore newRateLimitStore(LettuceConnectionFactory factory) {
|
||||
ValkeyRateLimitStore store = new ValkeyRateLimitStore(factory);
|
||||
store.initProxyManager();
|
||||
return store;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("JobStore put is atomic (hash + TTL + reverse index visible together)")
|
||||
void jobStorePutIsAtomic() {
|
||||
ValkeyJobStore store = new ValkeyJobStore(templateA);
|
||||
String jobId = "atomic-job-" + java.util.UUID.randomUUID();
|
||||
String fileId = "atomic-file-" + java.util.UUID.randomUUID();
|
||||
store.put(
|
||||
new JobStoreEntry(
|
||||
jobId,
|
||||
JobStoreEntry.JobState.PENDING,
|
||||
"node-A",
|
||||
Instant.now(),
|
||||
null,
|
||||
null,
|
||||
List.of(fileId),
|
||||
Map.of("k", "v")),
|
||||
Duration.ofSeconds(30));
|
||||
|
||||
// After put returns, every artifact has to be observable - if any are missing, the
|
||||
// MULTI/EXEC was not really atomic.
|
||||
assertTrue(store.exists(jobId), "hash must be visible after put");
|
||||
Long jobTtl =
|
||||
templateA.getExpire(
|
||||
"stirling:job:" + jobId, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
assertNotNull(jobTtl);
|
||||
assertTrue(jobTtl > 0, "hash must have TTL armed inside the same transaction");
|
||||
assertEquals(jobId, store.findJobIdByFileId(fileId).orElse(null));
|
||||
Long indexTtl =
|
||||
templateA.getExpire(
|
||||
"stirling:file2job:" + fileId, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
assertNotNull(indexTtl);
|
||||
assertTrue(indexTtl > 0, "reverse index must also have TTL armed");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"JobStore.delete(): WATCH aborts when put() races between read and EXEC, no orphaned"
|
||||
+ " reverse-index entries")
|
||||
void jobStoreDeleteWatchRaceRetriesAndCleansUp() {
|
||||
ValkeyJobStore store = new ValkeyJobStore(templateA);
|
||||
String jobId = "watch-race-job-" + java.util.UUID.randomUUID();
|
||||
String originalFile = "orig-file-" + java.util.UUID.randomUUID();
|
||||
String newFile = "new-file-" + java.util.UUID.randomUUID();
|
||||
|
||||
store.put(
|
||||
new JobStoreEntry(
|
||||
jobId,
|
||||
JobStoreEntry.JobState.RUNNING,
|
||||
"node-A",
|
||||
Instant.now(),
|
||||
null,
|
||||
null,
|
||||
List.of(originalFile),
|
||||
Map.of()),
|
||||
Duration.ofSeconds(30));
|
||||
|
||||
// Simulate the race: between delete()'s read and EXEC, another node adds newFile to
|
||||
// the same job. With WATCH/MULTI/EXEC the first EXEC aborts; the retry sees the
|
||||
// updated fileIds and deletes both reverse-index entries.
|
||||
Thread mutator =
|
||||
new Thread(
|
||||
() -> {
|
||||
try {
|
||||
Thread.sleep(20);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
store.put(
|
||||
new JobStoreEntry(
|
||||
jobId,
|
||||
JobStoreEntry.JobState.RUNNING,
|
||||
"node-A",
|
||||
Instant.now(),
|
||||
null,
|
||||
null,
|
||||
List.of(originalFile, newFile),
|
||||
Map.of()),
|
||||
Duration.ofSeconds(30));
|
||||
});
|
||||
mutator.start();
|
||||
|
||||
store.delete(jobId);
|
||||
try {
|
||||
mutator.join(2000);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
// Whichever order won, the final state must be self-consistent: either the hash is
|
||||
// deleted and both reverse-index entries are gone, OR the second put() committed
|
||||
// after delete and the hash + reverse-index entries for BOTH fileIds are intact.
|
||||
boolean hashGone = !store.exists(jobId);
|
||||
boolean origIndexGone = !store.findJobIdByFileId(originalFile).isPresent();
|
||||
boolean newIndexGone = !store.findJobIdByFileId(newFile).isPresent();
|
||||
if (hashGone) {
|
||||
assertTrue(
|
||||
origIndexGone,
|
||||
"if hash is deleted, original reverse-index entry must also be gone");
|
||||
assertTrue(
|
||||
newIndexGone,
|
||||
"if hash is deleted after the racing put(), the WATCH retry must catch the"
|
||||
+ " new fileId and delete its reverse-index entry too");
|
||||
} else {
|
||||
// The racing put() committed after delete completed; both indices should point at
|
||||
// jobId. This is a legitimate outcome - delete and re-put is not an atomic API.
|
||||
assertEquals(jobId, store.findJobIdByFileId(originalFile).orElse(null));
|
||||
assertEquals(jobId, store.findJobIdByFileId(newFile).orElse(null));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("JobStore.delete() removes hash AND every reverse-index entry atomically")
|
||||
void jobStoreDeleteRemovesReverseIndexEntries() {
|
||||
ValkeyJobStore store = new ValkeyJobStore(templateA);
|
||||
String jobId = "del-atomic-job-" + java.util.UUID.randomUUID();
|
||||
String fileA = "del-atomic-fileA-" + java.util.UUID.randomUUID();
|
||||
String fileB = "del-atomic-fileB-" + java.util.UUID.randomUUID();
|
||||
store.put(
|
||||
new JobStoreEntry(
|
||||
jobId,
|
||||
JobStoreEntry.JobState.COMPLETE,
|
||||
"node-A",
|
||||
Instant.now(),
|
||||
Instant.now(),
|
||||
null,
|
||||
List.of(fileA, fileB),
|
||||
Map.of()),
|
||||
Duration.ofSeconds(30));
|
||||
// Sanity: every artifact is in place before delete.
|
||||
assertTrue(store.exists(jobId));
|
||||
assertEquals(jobId, store.findJobIdByFileId(fileA).orElse(null));
|
||||
assertEquals(jobId, store.findJobIdByFileId(fileB).orElse(null));
|
||||
|
||||
store.delete(jobId);
|
||||
|
||||
// Both the main hash AND every reverse-index entry must be gone; dangling reverse-index
|
||||
// entries would cause findJobIdByFileId() to return a deleted jobId.
|
||||
assertFalse(store.exists(jobId), "main hash must be deleted");
|
||||
assertFalse(
|
||||
store.findJobIdByFileId(fileA).isPresent(),
|
||||
"reverse-index entry for fileA must not survive delete()");
|
||||
assertFalse(
|
||||
store.findJobIdByFileId(fileB).isPresent(),
|
||||
"reverse-index entry for fileB must not survive delete()");
|
||||
assertFalse(
|
||||
Boolean.TRUE.equals(templateA.hasKey("stirling:file2job:" + fileA)),
|
||||
"raw reverse-index key for fileA must not survive delete()");
|
||||
assertFalse(
|
||||
Boolean.TRUE.equals(templateA.hasKey("stirling:file2job:" + fileB)),
|
||||
"raw reverse-index key for fileB must not survive delete()");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("JobStore.all() walks the keyspace via SCAN, not KEYS")
|
||||
void jobStoreAllUsesScanNonBlocking() {
|
||||
ValkeyJobStore store = new ValkeyJobStore(templateA);
|
||||
// Seed a handful of keys; the goal is "we get them all back" - the non-blocking property
|
||||
// of SCAN is a property of the production server, what we verify here is functional parity.
|
||||
for (int i = 0; i < 15; i++) {
|
||||
store.put(
|
||||
new JobStoreEntry(
|
||||
"scan-job-" + i,
|
||||
JobStoreEntry.JobState.PENDING,
|
||||
"node-A",
|
||||
Instant.now(),
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
Map.of()),
|
||||
Duration.ofSeconds(30));
|
||||
}
|
||||
long observed = store.all().stream().filter(e -> e.jobId().startsWith("scan-job-")).count();
|
||||
assertTrue(
|
||||
observed >= 15,
|
||||
"SCAN-based all() must surface every inserted job, saw " + observed);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Valkey unreachable yields isHealthy() = false")
|
||||
void unreachableBackplaneReportsUnhealthy() {
|
||||
// Point at a closed port; afterPropertiesSet may succeed but ping will fail.
|
||||
RedisStandaloneConfiguration cfg = new RedisStandaloneConfiguration("localhost", 16400);
|
||||
LettuceConnectionFactory dead = new LettuceConnectionFactory(cfg);
|
||||
dead.afterPropertiesSet();
|
||||
try {
|
||||
StringRedisTemplate t = new StringRedisTemplate(dead);
|
||||
ValkeyClusterBackplane bp = new ValkeyClusterBackplane(newProps("orphan"), t);
|
||||
assertFalse(bp.isHealthy(), "isHealthy must be false when Valkey is unreachable");
|
||||
} finally {
|
||||
dead.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private ApplicationProperties newProps(String nodeId) {
|
||||
ApplicationProperties p = new ApplicationProperties();
|
||||
p.getCluster().setEnabled(true);
|
||||
p.getCluster().setBackplane("valkey");
|
||||
p.getCluster()
|
||||
.getValkey()
|
||||
.setUrl("redis://" + VALKEY.getHost() + ":" + VALKEY.getMappedPort(6379));
|
||||
p.getCluster().getNode().setId(nodeId);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package stirling.software.proprietary.cluster.valkey;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.core.RedisCallback;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* S3 regression: {@link ValkeyClusterBackplane#isHealthy()} must route through {@code
|
||||
* template.execute(...)} so the borrowed connection is always returned to the pool. Calling {@code
|
||||
* getConnectionFactory().getConnection()} directly would leak the connection on every k8s liveness
|
||||
* probe tick and exhaust the pool under monitoring load.
|
||||
*/
|
||||
class ValkeyClusterBackplaneTest {
|
||||
|
||||
@Test
|
||||
void isHealthy_routesThroughTemplateExecute_andDoesNotTouchConnectionFactoryDirectly() {
|
||||
StringRedisTemplate template = mock(StringRedisTemplate.class);
|
||||
when(template.execute(any(RedisCallback.class))).thenReturn("PONG");
|
||||
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getCluster().getNode().setId("n-1");
|
||||
ValkeyClusterBackplane bp = new ValkeyClusterBackplane(props, template);
|
||||
|
||||
assertTrue(bp.isHealthy());
|
||||
verify(template, times(1)).execute(any(RedisCallback.class));
|
||||
// Critical: never bypass the template's connection management.
|
||||
verify(template, never()).getConnectionFactory();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isHealthy_returnsFalseWhenExecuteThrows() {
|
||||
StringRedisTemplate template = mock(StringRedisTemplate.class);
|
||||
when(template.execute(any(RedisCallback.class))).thenThrow(new RuntimeException("boom"));
|
||||
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getCluster().getNode().setId("n-1");
|
||||
ValkeyClusterBackplane bp = new ValkeyClusterBackplane(props, template);
|
||||
|
||||
assertFalse(bp.isHealthy());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRunLocalCleanup_returnsFalse_valkeyOwnsTtlEviction() {
|
||||
// Valkey expires job entries via the TTL set in ValkeyJobStore.put(); running the local
|
||||
// TaskManager.cleanupOldJobs loop on top of that is redundant and would create races
|
||||
// with cluster-visible state. Default in ClusterBackplane is true; this override flips
|
||||
// it for the Valkey impl.
|
||||
StringRedisTemplate template = mock(StringRedisTemplate.class);
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getCluster().getNode().setId("n-1");
|
||||
ValkeyClusterBackplane bp = new ValkeyClusterBackplane(props, template);
|
||||
assertFalse(bp.shouldRunLocalCleanup());
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package stirling.software.proprietary.cluster.valkey;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.atMost;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.RedisSystemException;
|
||||
import org.springframework.data.redis.connection.RedisConnection;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
|
||||
import io.lettuce.core.RedisCommandExecutionException;
|
||||
import io.lettuce.core.SslVerifyMode;
|
||||
|
||||
/**
|
||||
* Unit tests for the auth-fast-fail behaviour of {@link
|
||||
* ValkeyConnectionConfiguration#eagerHandshake(LettuceConnectionFactory, String, int, boolean)} and
|
||||
* the auth-detection helper {@link ValkeyConnectionConfiguration#isAuthFailure(Throwable)}.
|
||||
*
|
||||
* <p>An auth-class failure (WRONGPASS / NOAUTH / NOPERM) is unrecoverable; retrying for 30 s only
|
||||
* delays the inevitable boot failure and floods logs. The handshake must surface auth errors after
|
||||
* exactly one attempt.
|
||||
*/
|
||||
class ValkeyConnectionConfigurationTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("WRONGPASS surfaces in one attempt (no 30s retry loop)")
|
||||
void wrongpass_failsImmediately_withoutRetries() throws Exception {
|
||||
LettuceConnectionFactory factory = mock(LettuceConnectionFactory.class);
|
||||
RedisConnection conn = mock(RedisConnection.class);
|
||||
when(factory.getConnection()).thenReturn(conn);
|
||||
// Spring Data Redis wraps RedisCommandExecutionException in RedisSystemException; we
|
||||
// simulate the exact wrapper Lettuce → spring-data-redis produces in production.
|
||||
RedisCommandExecutionException auth =
|
||||
new RedisCommandExecutionException("WRONGPASS invalid username-password pair");
|
||||
when(conn.ping()).thenThrow(new RedisSystemException("Error in execution", auth));
|
||||
|
||||
long start = System.nanoTime();
|
||||
IllegalStateException ex =
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() ->
|
||||
ValkeyConnectionConfiguration.eagerHandshake(
|
||||
factory, "valkey", 6379, false));
|
||||
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||
|
||||
// Exactly one ping call. A retry loop would call it 10 times with 3 s sleeps.
|
||||
verify(factory, times(1)).getConnection();
|
||||
verify(conn, times(1)).ping();
|
||||
// Generous 1500 ms bound; the single attempt with a mocked connection is sub-ms in
|
||||
// practice. The contract is "no 3 s+ sleeps".
|
||||
assertTrue(
|
||||
elapsedMs < 1500,
|
||||
"Auth failure must short-circuit retries; elapsed=" + elapsedMs + " ms");
|
||||
assertTrue(
|
||||
ex.getMessage().contains("authentication failed"),
|
||||
"Error message must explain the auth failure; got: " + ex.getMessage());
|
||||
verify(factory, atMost(1)).destroy();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("NOAUTH surfaces in one attempt")
|
||||
void noauth_failsImmediately() {
|
||||
LettuceConnectionFactory factory = mock(LettuceConnectionFactory.class);
|
||||
RedisConnection conn = mock(RedisConnection.class);
|
||||
when(factory.getConnection()).thenReturn(conn);
|
||||
when(conn.ping())
|
||||
.thenThrow(
|
||||
new RedisSystemException(
|
||||
"Error in execution",
|
||||
new RedisCommandExecutionException(
|
||||
"NOAUTH Authentication required.")));
|
||||
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> ValkeyConnectionConfiguration.eagerHandshake(factory, "v", 6379, false));
|
||||
verify(conn, times(1)).ping();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("NOPERM surfaces in one attempt")
|
||||
void noperm_failsImmediately() {
|
||||
LettuceConnectionFactory factory = mock(LettuceConnectionFactory.class);
|
||||
RedisConnection conn = mock(RedisConnection.class);
|
||||
when(factory.getConnection()).thenReturn(conn);
|
||||
when(conn.ping())
|
||||
.thenThrow(
|
||||
new RedisSystemException(
|
||||
"Error in execution",
|
||||
new RedisCommandExecutionException(
|
||||
"NOPERM this user has no permissions to run the 'ping'"
|
||||
+ " command")));
|
||||
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> ValkeyConnectionConfiguration.eagerHandshake(factory, "v", 6379, false));
|
||||
verify(conn, times(1)).ping();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isAuthFailure - direct RedisCommandExecutionException with auth prefix")
|
||||
void isAuthFailure_directRedisCommandExecutionException() {
|
||||
assertTrue(
|
||||
ValkeyConnectionConfiguration.isAuthFailure(
|
||||
new RedisCommandExecutionException("WRONGPASS bad password")));
|
||||
assertTrue(
|
||||
ValkeyConnectionConfiguration.isAuthFailure(
|
||||
new RedisCommandExecutionException("NOAUTH required")));
|
||||
assertTrue(
|
||||
ValkeyConnectionConfiguration.isAuthFailure(
|
||||
new RedisCommandExecutionException("NOPERM denied")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isAuthFailure - wrapped inside RedisSystemException (production path)")
|
||||
void isAuthFailure_wrappedBySpring() {
|
||||
assertTrue(
|
||||
ValkeyConnectionConfiguration.isAuthFailure(
|
||||
new RedisSystemException(
|
||||
"Error in execution",
|
||||
new RedisCommandExecutionException("WRONGPASS bad password"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isAuthFailure - connection errors do NOT count as auth failures")
|
||||
void isAuthFailure_connectionErrorReturnsFalse() {
|
||||
// A transport-level failure must continue to retry.
|
||||
assertFalse(
|
||||
ValkeyConnectionConfiguration.isAuthFailure(
|
||||
new RedisSystemException(
|
||||
"Redis connection failed",
|
||||
new io.lettuce.core.RedisConnectionException(
|
||||
"Connection refused"))));
|
||||
assertFalse(
|
||||
ValkeyConnectionConfiguration.isAuthFailure(
|
||||
new IllegalStateException("Valkey PING returned 'foo' (expected PONG)")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("bad PONG (protocol error) is not an auth failure")
|
||||
void unexpectedPong_isNotAuthFailure() {
|
||||
// Sanity: a returned non-PONG string maps to IllegalStateException inside the try block
|
||||
// and must not be treated as auth, otherwise misclassified protocol errors would skip
|
||||
// the retry loop too.
|
||||
assertFalse(
|
||||
ValkeyConnectionConfiguration.isAuthFailure(
|
||||
new IllegalStateException("Valkey PING returned 'bar' (expected PONG)")));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
// D5: TLS hostname/chain verification (default ON, opt-out for dev only)
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("TLS on, skipCertVerification=false → useSsl + verifyPeer=FULL (default)")
|
||||
void tls_defaultEnforcesFullPeerVerification() {
|
||||
LettuceClientConfiguration cfg =
|
||||
ValkeyConnectionConfiguration.buildClientConfiguration(true, false);
|
||||
assertTrue(cfg.isUseSsl(), "TLS must be enabled");
|
||||
// FULL = chain + hostname. CA-only or NONE would be a silent downgrade and is why we
|
||||
// pin this explicitly rather than relying on the upstream Spring default.
|
||||
assertSame(SslVerifyMode.FULL, cfg.getVerifyMode());
|
||||
assertTrue(cfg.isVerifyPeer());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("TLS on, skipCertVerification=true → verifyPeer=NONE (dev override)")
|
||||
void tls_skipCertVerificationOptOut() {
|
||||
LettuceClientConfiguration cfg =
|
||||
ValkeyConnectionConfiguration.buildClientConfiguration(true, true);
|
||||
assertTrue(cfg.isUseSsl());
|
||||
// The opt-out path is intentionally available for self-signed local dev certs, but
|
||||
// requires explicit operator action via cluster.valkey.tls.skip-cert-verification.
|
||||
assertSame(SslVerifyMode.NONE, cfg.getVerifyMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("TLS off → no SSL, verify flag default (skipCertVerification ignored)")
|
||||
void noTls_ignoresSkipFlag() {
|
||||
// Without rediss:// we never call useSsl(), so the skip flag is a no-op. Confirming
|
||||
// here so we cannot accidentally trip TLS off on plain redis:// connections.
|
||||
LettuceClientConfiguration cfg =
|
||||
ValkeyConnectionConfiguration.buildClientConfiguration(false, true);
|
||||
assertFalse(cfg.isUseSsl());
|
||||
}
|
||||
}
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
package stirling.software.proprietary.security.configuration.ee;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
class EEAppConfigTest {
|
||||
|
||||
@Test
|
||||
void ssoAutoLogin_disabled_returnsFalse_andDoesNotConsultLicense() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getPremium().getProFeatures().setSsoAutoLogin(false);
|
||||
LicenseKeyChecker checker = mock(LicenseKeyChecker.class);
|
||||
|
||||
EEAppConfig cfg = new EEAppConfig(props, checker);
|
||||
|
||||
assertThat(cfg.ssoAutoLogin()).isFalse();
|
||||
verifyNoInteractions(checker);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ssoAutoLogin_enabled_withProLicense_returnsTrue() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getPremium().getProFeatures().setSsoAutoLogin(true);
|
||||
LicenseKeyChecker checker = mock(LicenseKeyChecker.class);
|
||||
when(checker.getPremiumLicenseEnabledResult())
|
||||
.thenReturn(KeygenLicenseVerifier.License.SERVER);
|
||||
|
||||
EEAppConfig cfg = new EEAppConfig(props, checker);
|
||||
|
||||
assertThat(cfg.ssoAutoLogin()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ssoAutoLogin_enabled_withoutLicense_throwsAtBootTime() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getPremium().getProFeatures().setSsoAutoLogin(true);
|
||||
LicenseKeyChecker checker = mock(LicenseKeyChecker.class);
|
||||
// Real LicenseKeyChecker.requireProOrEnterprise throws on NORMAL; mock that behavior here.
|
||||
org.mockito.Mockito.doThrow(
|
||||
new IllegalStateException(
|
||||
"premium.proFeatures.ssoAutoLogin=true requires a Pro or Enterprise license"))
|
||||
.when(checker)
|
||||
.requireProOrEnterprise("premium.proFeatures.ssoAutoLogin=true");
|
||||
|
||||
EEAppConfig cfg = new EEAppConfig(props, checker);
|
||||
|
||||
assertThatThrownBy(cfg::ssoAutoLogin)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining(
|
||||
"premium.proFeatures.ssoAutoLogin=true requires a Pro or Enterprise license");
|
||||
}
|
||||
}
|
||||
-41
@@ -1,7 +1,5 @@
|
||||
package stirling.software.proprietary.security.configuration.ee;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
@@ -88,43 +86,4 @@ class LicenseKeyCheckerTest {
|
||||
assertEquals(License.NORMAL, checker.getPremiumLicenseEnabledResult());
|
||||
verifyNoInteractions(verifier);
|
||||
}
|
||||
|
||||
// ----- requireProOrEnterprise: shared boot-time gate for premium features -----
|
||||
|
||||
@Test
|
||||
void requireProOrEnterprise_normalLicense_throwsWithFeatureName() {
|
||||
LicenseKeyChecker checker = checkerWithLicense(License.NORMAL);
|
||||
assertThatThrownBy(() -> checker.requireProOrEnterprise("storage.provider=s3"))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("storage.provider=s3 requires a Pro or Enterprise license");
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireProOrEnterprise_serverLicense_passes() {
|
||||
LicenseKeyChecker checker = checkerWithLicense(License.SERVER);
|
||||
assertThatCode(() -> checker.requireProOrEnterprise("any.feature=true"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireProOrEnterprise_enterpriseLicense_passes() {
|
||||
LicenseKeyChecker checker = checkerWithLicense(License.ENTERPRISE);
|
||||
assertThatCode(() -> checker.requireProOrEnterprise("any.feature=true"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
private LicenseKeyChecker checkerWithLicense(License level) {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
if (level == License.NORMAL) {
|
||||
props.getPremium().setEnabled(false);
|
||||
} else {
|
||||
props.getPremium().setEnabled(true);
|
||||
props.getPremium().setKey("any");
|
||||
when(verifier.verifyLicense("any")).thenReturn(level);
|
||||
}
|
||||
LicenseKeyChecker checker =
|
||||
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
|
||||
checker.init();
|
||||
return checker;
|
||||
}
|
||||
}
|
||||
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
package stirling.software.proprietary.security.filter;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.commons.codec.digest.DigestUtils;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.mock.web.MockFilterChain;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import stirling.software.common.cluster.RateLimitStore;
|
||||
import stirling.software.common.cluster.RateLimitStore.RateLimitDecision;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.cluster.ClusterMetrics;
|
||||
|
||||
/** Contract tests for {@link UserBasedRateLimitingFilter}. */
|
||||
class UserBasedRateLimitingFilterTest {
|
||||
|
||||
private RateLimitStore rateLimitStore;
|
||||
private ClusterMetrics clusterMetrics;
|
||||
private UserBasedRateLimitingFilter filter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
rateLimitStore = Mockito.mock(RateLimitStore.class);
|
||||
clusterMetrics = Mockito.mock(ClusterMetrics.class);
|
||||
filter = new UserBasedRateLimitingFilter(true, rateLimitStore);
|
||||
Field f =
|
||||
UserBasedRateLimitingFilter.class.getDeclaredField(
|
||||
"clusterMetrics"); // optional @Autowired - inject via reflection
|
||||
f.setAccessible(true);
|
||||
f.set(filter, clusterMetrics);
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private MockHttpServletRequest postRequest() {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.setMethod("POST");
|
||||
req.setRemoteAddr("203.0.113.7");
|
||||
return req;
|
||||
}
|
||||
|
||||
private void authenticateAs(String username, String roleId) {
|
||||
UsernamePasswordAuthenticationToken auth =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
new org.springframework.security.core.userdetails.User(
|
||||
username, "x", List.of(new SimpleGrantedAuthority(roleId))),
|
||||
"x",
|
||||
List.of(new SimpleGrantedAuthority(roleId)));
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledFlag_shortCircuitsFilterChain() throws Exception {
|
||||
UserBasedRateLimitingFilter disabled =
|
||||
new UserBasedRateLimitingFilter(false, rateLimitStore);
|
||||
MockHttpServletRequest req = postRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
disabled.doFilter(req, res, chain);
|
||||
|
||||
assertEquals(200, res.getStatus(), "no rate-limit decision should be made");
|
||||
verify(rateLimitStore, never()).tryConsume(anyString(), anyLong(), any());
|
||||
assertNotNull(chain.getRequest(), "downstream filter should have been invoked");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonPostRequest_bypassesRateLimit() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.setMethod("GET");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
verify(rateLimitStore, never()).tryConsume(anyString(), anyLong(), any());
|
||||
assertNotNull(chain.getRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowedRequest_passesThroughAndSetsRemainingHeader() throws Exception {
|
||||
authenticateAs("alice", Role.ADMIN.getRoleId());
|
||||
when(rateLimitStore.tryConsume(eq("web:alice"), anyLong(), eq(Duration.ofDays(1))))
|
||||
.thenReturn(new RateLimitDecision(true, 42L, 0L));
|
||||
|
||||
MockHttpServletRequest req = postRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
assertEquals(200, res.getStatus());
|
||||
assertEquals("42", res.getHeader("X-Rate-Limit-Remaining"));
|
||||
verify(clusterMetrics, never()).recordRateLimitReject();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deniedRequest_returns429_recordsMetric_writesBody() throws Exception {
|
||||
authenticateAs("bob", Role.WEB_ONLY_USER.getRoleId());
|
||||
when(rateLimitStore.tryConsume(eq("web:bob"), anyLong(), any()))
|
||||
.thenReturn(new RateLimitDecision(false, 0L, Duration.ofSeconds(37).toNanos()));
|
||||
|
||||
MockHttpServletRequest req = postRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
assertEquals(429, res.getStatus());
|
||||
assertEquals("37", res.getHeader("X-Rate-Limit-Retry-After-Seconds"));
|
||||
assertEquals("Rate limit exceeded for POST requests.", res.getContentAsString());
|
||||
verify(clusterMetrics, times(1)).recordRateLimitReject();
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiKeyRequest_usesApiScopeAndApiQuota() throws Exception {
|
||||
String apiKey = "kkk";
|
||||
String expectedBucket = "api:API_KEY_" + DigestUtils.sha256Hex(apiKey);
|
||||
when(rateLimitStore.tryConsume(eq(expectedBucket), eq(40L), eq(Duration.ofDays(1))))
|
||||
.thenReturn(new RateLimitDecision(true, 39L, 0L));
|
||||
|
||||
MockHttpServletRequest req = postRequest();
|
||||
req.addHeader("X-API-KEY", apiKey);
|
||||
// Authentication still has to expose a role for getRoleFromAuthentication() to be happy.
|
||||
authenticateAs("svc", Role.LIMITED_API_USER.getRoleId());
|
||||
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
assertEquals(200, res.getStatus());
|
||||
verify(rateLimitStore).tryConsume(expectedBucket, 40L, Duration.ofDays(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiKeyRequest_bucketKey_containsHashNotRawKey() throws Exception {
|
||||
String rawApiKey = "secret-super-sensitive-value-xyzzy";
|
||||
when(rateLimitStore.tryConsume(anyString(), anyLong(), any()))
|
||||
.thenReturn(new RateLimitDecision(true, 1L, 0L));
|
||||
|
||||
MockHttpServletRequest req = postRequest();
|
||||
req.addHeader("X-API-KEY", rawApiKey);
|
||||
authenticateAs("svc", Role.LIMITED_API_USER.getRoleId());
|
||||
|
||||
filter.doFilter(req, new MockHttpServletResponse(), new MockFilterChain());
|
||||
|
||||
ArgumentCaptor<String> bucket = ArgumentCaptor.forClass(String.class);
|
||||
verify(rateLimitStore).tryConsume(bucket.capture(), anyLong(), any());
|
||||
|
||||
String captured = bucket.getValue();
|
||||
assertEquals(-1, captured.indexOf(rawApiKey), "raw API key must not appear in bucket key");
|
||||
String expectedHash = DigestUtils.sha256Hex(rawApiKey);
|
||||
assertNotNull(expectedHash);
|
||||
assertTrue(
|
||||
captured.contains(expectedHash),
|
||||
"bucket key must contain SHA-256 hash of API key, got: " + captured);
|
||||
}
|
||||
|
||||
@Test
|
||||
void webRequest_unauthenticated_usesRemoteAddrAsIdentifier() throws Exception {
|
||||
when(rateLimitStore.tryConsume(eq("web:203.0.113.7"), eq(20L), eq(Duration.ofDays(1))))
|
||||
.thenReturn(new RateLimitDecision(true, 19L, 0L));
|
||||
|
||||
MockHttpServletRequest req = postRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
assertEquals(200, res.getStatus(), "anonymous request must not 500");
|
||||
verify(rateLimitStore).tryConsume("web:203.0.113.7", 20L, Duration.ofDays(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void webRequest_anonymousToken_treatedAsRestrictiveRole_notFiveHundred() throws Exception {
|
||||
AnonymousAuthenticationToken anon =
|
||||
new AnonymousAuthenticationToken(
|
||||
"key",
|
||||
"anonymousUser",
|
||||
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
|
||||
SecurityContextHolder.getContext().setAuthentication(anon);
|
||||
when(rateLimitStore.tryConsume(eq("web:203.0.113.7"), eq(20L), eq(Duration.ofDays(1))))
|
||||
.thenReturn(new RateLimitDecision(true, 19L, 0L));
|
||||
|
||||
MockHttpServletRequest req = postRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
assertEquals(200, res.getStatus(), "anonymous-token request must not 500");
|
||||
verify(rateLimitStore).tryConsume("web:203.0.113.7", 20L, Duration.ofDays(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deniedRequest_withZeroNanos_emitsZeroRetryAfter() throws Exception {
|
||||
authenticateAs("c", Role.WEB_ONLY_USER.getRoleId());
|
||||
when(rateLimitStore.tryConsume(anyString(), anyLong(), any()))
|
||||
.thenReturn(new RateLimitDecision(false, 0L, 0L));
|
||||
|
||||
MockHttpServletRequest req = postRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
assertEquals(429, res.getStatus());
|
||||
assertEquals("0", res.getHeader("X-Rate-Limit-Retry-After-Seconds"));
|
||||
verify(clusterMetrics).recordRateLimitReject();
|
||||
}
|
||||
|
||||
@Test
|
||||
void filterStillWorksWhenClusterMetricsAbsent() throws Exception {
|
||||
// Reproduces single-instance mode where ClusterMetrics is not on the classpath.
|
||||
UserBasedRateLimitingFilter bare = new UserBasedRateLimitingFilter(true, rateLimitStore);
|
||||
// intentionally leave clusterMetrics null
|
||||
authenticateAs("solo", Role.WEB_ONLY_USER.getRoleId());
|
||||
when(rateLimitStore.tryConsume(anyString(), anyLong(), any()))
|
||||
.thenReturn(new RateLimitDecision(false, 0L, Duration.ofSeconds(5).toNanos()));
|
||||
|
||||
MockHttpServletRequest req = postRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
bare.doFilter(req, res, chain);
|
||||
|
||||
assertEquals(429, res.getStatus(), "rejection still emitted");
|
||||
// No exception thrown despite clusterMetrics being null - that is the property we want.
|
||||
}
|
||||
|
||||
@Test
|
||||
void identifier_includesAuthenticatedUsername_notRemoteAddr() throws Exception {
|
||||
// Two requests from the same IP but different users must NOT collide.
|
||||
authenticateAs("user1", Role.WEB_ONLY_USER.getRoleId());
|
||||
when(rateLimitStore.tryConsume(anyString(), anyLong(), any()))
|
||||
.thenReturn(new RateLimitDecision(true, 5L, 0L));
|
||||
|
||||
MockHttpServletRequest req = postRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
verify(rateLimitStore).tryConsume(eq("web:user1"), anyLong(), any());
|
||||
|
||||
SecurityContextHolder.clearContext();
|
||||
authenticateAs("user2", Role.WEB_ONLY_USER.getRoleId());
|
||||
MockHttpServletRequest req2 = postRequest();
|
||||
filter.doFilter(req2, new MockHttpServletResponse(), new MockFilterChain());
|
||||
verify(rateLimitStore).tryConsume(eq("web:user2"), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void newlineInjection_inRemainingHeader_isStripped() throws Exception {
|
||||
// If somehow a malicious refill value carried a newline, the filter must not pass it
|
||||
// through. The current implementation strips via Newlines + regex on Long.toString.
|
||||
// Long.toString can never produce a newline, but we still assert the contract.
|
||||
authenticateAs("e", Role.WEB_ONLY_USER.getRoleId());
|
||||
when(rateLimitStore.tryConsume(anyString(), anyLong(), any()))
|
||||
.thenReturn(new RateLimitDecision(true, Long.MAX_VALUE, 0L));
|
||||
|
||||
MockHttpServletRequest req = postRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
filter.doFilter(req, res, new MockFilterChain());
|
||||
|
||||
String header = res.getHeader("X-Rate-Limit-Remaining");
|
||||
assertNotNull(header);
|
||||
assertEquals(-1, header.indexOf('\n'));
|
||||
assertEquals(-1, header.indexOf('\r'));
|
||||
}
|
||||
|
||||
@Test
|
||||
void backendOutage_failsOpen_allowsRequest_notFiveHundred() throws Exception {
|
||||
// Cluster mode with Valkey unreachable: tryConsume throws. The filter must NOT 500 every
|
||||
// POST - it fails open (allows the request) so a backplane outage doesn't take the API
|
||||
// down.
|
||||
authenticateAs("dora", Role.WEB_ONLY_USER.getRoleId());
|
||||
when(rateLimitStore.tryConsume(anyString(), anyLong(), any()))
|
||||
.thenThrow(new RuntimeException("Valkey command timeout"));
|
||||
|
||||
MockHttpServletRequest req = postRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
assertEquals(200, res.getStatus(), "rate-limit backend outage must fail open, not 500");
|
||||
assertNotNull(chain.getRequest(), "request must pass downstream when backend is down");
|
||||
verify(clusterMetrics, never()).recordRateLimitReject();
|
||||
}
|
||||
}
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
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.reset;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.commons.codec.digest.DigestUtils;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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.springframework.context.MessageSource;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import stirling.software.common.cluster.KeyValueCache;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
|
||||
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
|
||||
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.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
|
||||
import stirling.software.proprietary.storage.repository.FileShareRepository;
|
||||
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
|
||||
import stirling.software.proprietary.storage.repository.StoredFileRepository;
|
||||
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
|
||||
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
|
||||
import stirling.software.proprietary.workflow.service.UserServerCertificateService;
|
||||
|
||||
/** Contract tests for the distributed API-key cache in {@link UserService}. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ApiKeyCacheTest {
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private TeamRepository teamRepository;
|
||||
@Mock private AuthorityRepository authorityRepository;
|
||||
@Mock private PasswordEncoder passwordEncoder;
|
||||
@Mock private MessageSource messageSource;
|
||||
@Mock private SessionPersistentRegistry sessionRegistry;
|
||||
@Mock private DatabaseServiceInterface databaseService;
|
||||
@Mock private ApplicationProperties.Security.OAUTH2 oAuth2;
|
||||
@Mock private KeyValueCache keyValueCache;
|
||||
@Mock private PersistentLoginRepository persistentLoginRepository;
|
||||
@Mock private UserServerCertificateService userServerCertificateService;
|
||||
@Mock private WorkflowParticipantRepository workflowParticipantRepository;
|
||||
@Mock private WorkflowSessionRepository workflowSessionRepository;
|
||||
@Mock private StoredFileRepository storedFileRepository;
|
||||
@Mock private StorageCleanupEntryRepository storageCleanupEntryRepository;
|
||||
@Mock private FileShareRepository fileShareRepository;
|
||||
@Mock private FileShareAccessRepository fileShareAccessRepository;
|
||||
|
||||
@InjectMocks private UserService userService;
|
||||
|
||||
private static final String API_KEY = "my-api-key";
|
||||
private static final String API_KEY_NAMESPACE = "apikey";
|
||||
private static final String NEGATIVE_MARKER = "__none__";
|
||||
private static final String KEY_HASH = DigestUtils.sha256Hex(API_KEY);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {}
|
||||
|
||||
@Test
|
||||
void cacheHit_positive_skipsDbLookup() {
|
||||
User user = userWithKey("alice", API_KEY);
|
||||
when(keyValueCache.get(API_KEY_NAMESPACE, KEY_HASH)).thenReturn(Optional.of("alice"));
|
||||
when(userRepository.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(user));
|
||||
|
||||
Optional<User> result = userService.getUserByApiKey(API_KEY);
|
||||
|
||||
assertTrue(result.isPresent());
|
||||
assertSame(user, result.get());
|
||||
verify(userRepository, never()).findByApiKey(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cacheHit_butStoredKeyDrifted_evictsAndFallsThrough() {
|
||||
User stale = userWithKey("alice", "different-key-now");
|
||||
when(keyValueCache.get(API_KEY_NAMESPACE, KEY_HASH)).thenReturn(Optional.of("alice"));
|
||||
when(userRepository.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(stale));
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.empty());
|
||||
|
||||
Optional<User> result = userService.getUserByApiKey(API_KEY);
|
||||
|
||||
assertEquals(Optional.empty(), result);
|
||||
verify(keyValueCache).evict(API_KEY_NAMESPACE, KEY_HASH);
|
||||
verify(userRepository).findByApiKey(API_KEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cacheHit_negativeMarker_returnsEmptyWithoutDbLookup() {
|
||||
when(keyValueCache.get(API_KEY_NAMESPACE, KEY_HASH))
|
||||
.thenReturn(Optional.of(NEGATIVE_MARKER));
|
||||
|
||||
Optional<User> result = userService.getUserByApiKey(API_KEY);
|
||||
|
||||
assertEquals(Optional.empty(), result);
|
||||
verify(userRepository, never()).findByApiKey(anyString());
|
||||
verify(userRepository, never()).findByUsernameIgnoreCase(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cacheMiss_repoHit_populatesPositiveEntry() {
|
||||
User user = userWithKey("alice", API_KEY);
|
||||
when(keyValueCache.get(API_KEY_NAMESPACE, KEY_HASH)).thenReturn(Optional.empty());
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(user));
|
||||
|
||||
Optional<User> result = userService.getUserByApiKey(API_KEY);
|
||||
|
||||
assertTrue(result.isPresent());
|
||||
verify(keyValueCache)
|
||||
.put(eq(API_KEY_NAMESPACE), eq(KEY_HASH), eq("alice"), any(Duration.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cacheMiss_repoEmpty_populatesNegativeMarker() {
|
||||
when(keyValueCache.get(API_KEY_NAMESPACE, KEY_HASH)).thenReturn(Optional.empty());
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.empty());
|
||||
|
||||
Optional<User> result = userService.getUserByApiKey(API_KEY);
|
||||
|
||||
assertEquals(Optional.empty(), result);
|
||||
verify(keyValueCache)
|
||||
.put(eq(API_KEY_NAMESPACE), eq(KEY_HASH), eq(NEGATIVE_MARKER), any(Duration.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void negativeTtl_shorterThanPositiveTtl() {
|
||||
when(keyValueCache.get(API_KEY_NAMESPACE, KEY_HASH)).thenReturn(Optional.empty());
|
||||
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.empty());
|
||||
userService.getUserByApiKey(API_KEY);
|
||||
ArgumentCaptor<Duration> negativeTtl = ArgumentCaptor.forClass(Duration.class);
|
||||
verify(keyValueCache)
|
||||
.put(
|
||||
eq(API_KEY_NAMESPACE),
|
||||
eq(KEY_HASH),
|
||||
eq(NEGATIVE_MARKER),
|
||||
negativeTtl.capture());
|
||||
|
||||
reset(keyValueCache);
|
||||
when(keyValueCache.get(API_KEY_NAMESPACE, KEY_HASH)).thenReturn(Optional.empty());
|
||||
when(userRepository.findByApiKey(API_KEY))
|
||||
.thenReturn(Optional.of(userWithKey("alice", API_KEY)));
|
||||
userService.getUserByApiKey(API_KEY);
|
||||
ArgumentCaptor<Duration> positiveTtl = ArgumentCaptor.forClass(Duration.class);
|
||||
verify(keyValueCache)
|
||||
.put(eq(API_KEY_NAMESPACE), eq(KEY_HASH), eq("alice"), positiveTtl.capture());
|
||||
|
||||
assertTrue(
|
||||
negativeTtl.getValue().compareTo(positiveTtl.getValue()) < 0,
|
||||
"negative TTL must be shorter than positive TTL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullApiKey_bypassesCacheEntirely() {
|
||||
userService.getUserByApiKey(null);
|
||||
|
||||
verifyNoInteractions(keyValueCache);
|
||||
verify(userRepository).findByApiKey(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankApiKey_bypassesCacheEntirely() {
|
||||
userService.getUserByApiKey(" ");
|
||||
|
||||
verifyNoInteractions(keyValueCache);
|
||||
verify(userRepository).findByApiKey(" ");
|
||||
}
|
||||
|
||||
@Test
|
||||
void evictApiKeyCache_invokesCacheEvict() {
|
||||
userService.evictApiKeyCache(API_KEY);
|
||||
verify(keyValueCache).evict(API_KEY_NAMESPACE, KEY_HASH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void evictApiKeyCache_nullOrBlankInputs_noOp() {
|
||||
userService.evictApiKeyCache(null);
|
||||
userService.evictApiKeyCache("");
|
||||
userService.evictApiKeyCache(" ");
|
||||
verifyNoInteractions(keyValueCache);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rotation_viaSaveUser_evictsPreviousKey() throws Exception {
|
||||
// Reach into the private saveUser(Optional<User>, String) helper to model rotation
|
||||
// happening as it does in production (addApiKeyToUser / refreshApiKeyForUser).
|
||||
User user = userWithKey("alice", "previous-key");
|
||||
String previousKey = user.getApiKey();
|
||||
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Method saveUser =
|
||||
UserService.class.getDeclaredMethod("saveUser", Optional.class, String.class);
|
||||
saveUser.setAccessible(true);
|
||||
saveUser.invoke(userService, Optional.of(user), "new-key");
|
||||
|
||||
verify(keyValueCache).evict(API_KEY_NAMESPACE, DigestUtils.sha256Hex(previousKey));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rotation_whenPreviousKeyBlank_skipsEviction() throws Exception {
|
||||
// First-time API key creation: no previous key to evict.
|
||||
User user = userWithKey("bob", null);
|
||||
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Method saveUser =
|
||||
UserService.class.getDeclaredMethod("saveUser", Optional.class, String.class);
|
||||
saveUser.setAccessible(true);
|
||||
saveUser.invoke(userService, Optional.of(user), "brand-new-key");
|
||||
|
||||
verify(keyValueCache, never()).evict(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncCustomApiUser_rotatesKey_evictsPreviousKeyFromClusterCache() {
|
||||
User existing = userWithKey("CUSTOM_API_USER", "old-custom-key");
|
||||
when(userRepository.findByUsernameIgnoreCase("CUSTOM_API_USER"))
|
||||
.thenReturn(Optional.of(existing));
|
||||
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
userService.syncCustomApiUser("new-custom-key");
|
||||
|
||||
verify(keyValueCache).evict(API_KEY_NAMESPACE, DigestUtils.sha256Hex("old-custom-key"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncCustomApiUser_keyUnchanged_noEviction() {
|
||||
// When the supplied key matches what is already stored, no save and no eviction.
|
||||
User existing = userWithKey("CUSTOM_API_USER", "stable-custom-key");
|
||||
when(userRepository.findByUsernameIgnoreCase("CUSTOM_API_USER"))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
userService.syncCustomApiUser("stable-custom-key");
|
||||
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
verify(keyValueCache, never()).evict(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncCustomApiUser_newUser_skipsEviction_noPreviousKey() {
|
||||
// First-time bootstrap: no previous key for the freshly-created CUSTOM_API_USER.
|
||||
when(userRepository.findByUsernameIgnoreCase("CUSTOM_API_USER"))
|
||||
.thenReturn(Optional.empty());
|
||||
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
userService.syncCustomApiUser("brand-new-key");
|
||||
|
||||
verify(keyValueCache, never()).evict(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUserByApiKey_isAlsoCached() {
|
||||
// Pre-condition: loadUserByApiKey goes through the same private cached helper.
|
||||
User user = userWithKey("alice", API_KEY);
|
||||
when(keyValueCache.get(API_KEY_NAMESPACE, KEY_HASH)).thenReturn(Optional.of("alice"));
|
||||
when(userRepository.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(user));
|
||||
|
||||
Optional<User> result = userService.loadUserByApiKey(API_KEY);
|
||||
|
||||
assertTrue(result.isPresent());
|
||||
verify(userRepository, never()).findByApiKey(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinctKeys_hashToDistinctCacheSlots() {
|
||||
// Smoke check: two random keys must not collide in the cache namespace.
|
||||
when(keyValueCache.get(API_KEY_NAMESPACE, DigestUtils.sha256Hex("k1")))
|
||||
.thenReturn(Optional.empty());
|
||||
when(keyValueCache.get(API_KEY_NAMESPACE, DigestUtils.sha256Hex("k2")))
|
||||
.thenReturn(Optional.empty());
|
||||
when(userRepository.findByApiKey("k1")).thenReturn(Optional.empty());
|
||||
when(userRepository.findByApiKey("k2")).thenReturn(Optional.empty());
|
||||
|
||||
userService.getUserByApiKey("k1");
|
||||
userService.getUserByApiKey("k2");
|
||||
|
||||
verify(keyValueCache, times(1))
|
||||
.put(
|
||||
eq(API_KEY_NAMESPACE),
|
||||
eq(DigestUtils.sha256Hex("k1")),
|
||||
eq(NEGATIVE_MARKER),
|
||||
any(Duration.class));
|
||||
verify(keyValueCache, times(1))
|
||||
.put(
|
||||
eq(API_KEY_NAMESPACE),
|
||||
eq(DigestUtils.sha256Hex("k2")),
|
||||
eq(NEGATIVE_MARKER),
|
||||
any(Duration.class));
|
||||
}
|
||||
|
||||
private User userWithKey(String username, String apiKey) {
|
||||
User user = new User();
|
||||
user.setUsername(username);
|
||||
user.setApiKey(apiKey);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
-221
@@ -1,221 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
|
||||
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
|
||||
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
|
||||
/**
|
||||
* Verifies the opt-in OAuth2/OIDC claim-dump diagnostic logging added to {@link
|
||||
* CustomOAuth2UserService} for troubleshooting provider misconfiguration (e.g. ADFS not emitting an
|
||||
* {@code email} claim).
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CustomOAuth2UserServiceDebugLoggingTest {
|
||||
|
||||
@Mock private UserService userService;
|
||||
@Mock private LoginAttemptService loginAttemptService;
|
||||
@Mock private OidcUserRequest userRequest;
|
||||
|
||||
private ListAppender<ILoggingEvent> appender;
|
||||
private Logger serviceLogger;
|
||||
|
||||
@BeforeEach
|
||||
void attachLogCapture() {
|
||||
serviceLogger = (Logger) LoggerFactory.getLogger(CustomOAuth2UserService.class);
|
||||
appender = new ListAppender<>();
|
||||
appender.start();
|
||||
serviceLogger.addAppender(appender);
|
||||
// Make sure INFO-level dumps reach the appender even if the default config is WARN+.
|
||||
serviceLogger.setLevel(Level.DEBUG);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void detachLogCapture() {
|
||||
serviceLogger.detachAppender(appender);
|
||||
appender.stop();
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenDebugLoggingOff_failureProducesNoClaimDump() throws Exception {
|
||||
ApplicationProperties.Security.OAUTH2 props = oauthProps("email", false);
|
||||
CustomOAuth2UserService service =
|
||||
new CustomOAuth2UserService(props, userService, loginAttemptService);
|
||||
// Provider gave us claims, but no "email" — same shape as the ADFS bug report.
|
||||
Map<String, Object> claims = baseClaims();
|
||||
claims.put("upn", "jdoe@demarest.com.br");
|
||||
replaceDelegateWithStub(service, claims);
|
||||
lenient()
|
||||
.when(userRequest.getIdToken())
|
||||
.thenReturn(new OidcIdToken("token", Instant.now(), Instant.MAX, claims));
|
||||
lenient().when(userRequest.getClientRegistration()).thenReturn(stubRegistration());
|
||||
|
||||
assertThrows(OAuth2AuthenticationException.class, () -> service.loadUser(userRequest));
|
||||
|
||||
assertThat(appender.list)
|
||||
.as("no debug dump should appear when debugLogging=false")
|
||||
.noneMatch(e -> e.getFormattedMessage().contains("[OAUTH2 DEBUG]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenDebugLoggingOn_failureDumpsClaimsAndSuggestsAlternative() throws Exception {
|
||||
ApplicationProperties.Security.OAUTH2 props = oauthProps("email", true);
|
||||
CustomOAuth2UserService service =
|
||||
new CustomOAuth2UserService(props, userService, loginAttemptService);
|
||||
Map<String, Object> claims = adfsStyleClaims();
|
||||
// ADFS-style: no `email`, but `preferred_username` IS a valid UsernameAttribute value.
|
||||
claims.put("preferred_username", "jdoe@demarest.com.br");
|
||||
// `upn` is NOT in UsernameAttribute, so it must NOT appear in the suggestion hint.
|
||||
claims.put("upn", "jdoe@demarest.com.br");
|
||||
replaceDelegateWithStub(service, claims);
|
||||
lenient()
|
||||
.when(userRequest.getIdToken())
|
||||
.thenReturn(new OidcIdToken("token", Instant.now(), Instant.MAX, claims));
|
||||
lenient().when(userRequest.getClientRegistration()).thenReturn(stubRegistration());
|
||||
|
||||
assertThrows(OAuth2AuthenticationException.class, () -> service.loadUser(userRequest));
|
||||
|
||||
List<ILoggingEvent> dumps =
|
||||
appender.list.stream()
|
||||
.filter(e -> e.getFormattedMessage().contains("[OAUTH2 DEBUG]"))
|
||||
.toList();
|
||||
assertThat(dumps).as("expected at least one debug-dump log line").isNotEmpty();
|
||||
|
||||
String combined =
|
||||
String.join("\n", dumps.stream().map(ILoggingEvent::getFormattedMessage).toList());
|
||||
assertThat(combined)
|
||||
.contains("Provider registrationId : demarest")
|
||||
.contains("Configured useAsUsername: email")
|
||||
.contains("preferred_username")
|
||||
.contains("upn = jdoe@demarest.com.br")
|
||||
.contains("<NULL — this is why login fails>");
|
||||
// The hint must include 'preferred_username' (a valid UsernameAttribute value present
|
||||
// in the claims) and MUST NOT include 'upn' (not in the UsernameAttribute enum).
|
||||
String hintLine =
|
||||
combined.lines()
|
||||
.filter(l -> l.contains("Hint:"))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AssertionError("no Hint: line in dump"));
|
||||
assertThat(hintLine).contains("preferred_username").doesNotContain("upn");
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidUseAsUsername_isWrappedAsOAuth2AuthenticationException() {
|
||||
// Regression: an earlier draft moved UsernameAttribute.valueOf(...) outside the try/catch,
|
||||
// so a typo'd or null useAsUsername leaked as a raw IllegalArgumentException instead of
|
||||
// being wrapped, breaking Spring's authentication exception handling. This test pins the
|
||||
// post-fix behaviour: valueOf() failures stay inside the guarded section.
|
||||
ApplicationProperties.Security.OAUTH2 props = oauthProps("not_a_real_attribute", true);
|
||||
CustomOAuth2UserService service =
|
||||
new CustomOAuth2UserService(props, userService, loginAttemptService);
|
||||
lenient().when(userRequest.getClientRegistration()).thenReturn(stubRegistration());
|
||||
// No need to stub the OIDC delegate — control flow shouldn't reach it.
|
||||
|
||||
OAuth2AuthenticationException thrown =
|
||||
assertThrows(
|
||||
OAuth2AuthenticationException.class, () -> service.loadUser(userRequest));
|
||||
assertThat(thrown.getCause()).isInstanceOf(IllegalArgumentException.class);
|
||||
// We deliberately do NOT emit the claim dump in this case (we have no resolved
|
||||
// usernameAttributeKey to compare against, and the IllegalArgumentException message
|
||||
// already explains the misconfiguration).
|
||||
assertThat(appender.list)
|
||||
.as("no claim dump when useAsUsername itself is invalid")
|
||||
.noneMatch(e -> e.getFormattedMessage().contains("[OAUTH2 DEBUG]"));
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private static ApplicationProperties.Security.OAUTH2 oauthProps(
|
||||
String useAsUsername, boolean debugLogging) {
|
||||
ApplicationProperties.Security.OAUTH2 p = new ApplicationProperties.Security.OAUTH2();
|
||||
p.setEnabled(true);
|
||||
p.setUseAsUsername(useAsUsername);
|
||||
p.setDebugLogging(debugLogging);
|
||||
return p;
|
||||
}
|
||||
|
||||
private static Map<String, Object> baseClaims() {
|
||||
Map<String, Object> claims = new LinkedHashMap<>();
|
||||
claims.put(IdTokenClaimNames.SUB, "abc-123");
|
||||
claims.put(IdTokenClaimNames.ISS, "https://sts.example.com/adfs");
|
||||
claims.put(IdTokenClaimNames.AUD, Collections.singletonList("client-id"));
|
||||
claims.put(IdTokenClaimNames.IAT, Instant.now());
|
||||
claims.put(IdTokenClaimNames.EXP, Instant.now().plusSeconds(3600));
|
||||
claims.put("given_name", "Jane");
|
||||
claims.put("family_name", "Doe");
|
||||
return claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* ADFS-style claim set with {@code given_name}/{@code family_name} removed, so the suggestion
|
||||
* hint test isolates a single expected UsernameAttribute value.
|
||||
*/
|
||||
private static Map<String, Object> adfsStyleClaims() {
|
||||
Map<String, Object> claims = baseClaims();
|
||||
claims.remove("given_name");
|
||||
claims.remove("family_name");
|
||||
return claims;
|
||||
}
|
||||
|
||||
private static ClientRegistration stubRegistration() {
|
||||
return ClientRegistration.withRegistrationId("demarest")
|
||||
.clientId("client-id")
|
||||
.clientSecret("client-secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri("https://app.example.com/login/oauth2/code/demarest")
|
||||
.authorizationUri("https://sts.example.com/adfs/oauth2/authorize")
|
||||
.tokenUri("https://sts.example.com/adfs/oauth2/token")
|
||||
.jwkSetUri("https://sts.example.com/adfs/discovery/keys")
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the private {@code delegate} field on {@link CustomOAuth2UserService} for a stub that
|
||||
* returns a {@link DefaultOidcUser} built from the supplied claims. Lets us drive the test
|
||||
* without standing up a real OIDC provider.
|
||||
*/
|
||||
private void replaceDelegateWithStub(
|
||||
CustomOAuth2UserService service, Map<String, Object> claims) throws Exception {
|
||||
OidcIdToken idToken =
|
||||
new OidcIdToken("raw-token", Instant.now(), Instant.MAX, new HashMap<>(claims));
|
||||
DefaultOidcUser delegateUser =
|
||||
new DefaultOidcUser(Collections.emptyList(), idToken, IdTokenClaimNames.SUB);
|
||||
OidcUserService delegateMock = org.mockito.Mockito.mock(OidcUserService.class);
|
||||
when(delegateMock.loadUser(any())).thenReturn(delegateUser);
|
||||
Field f = CustomOAuth2UserService.class.getDeclaredField("delegate");
|
||||
f.setAccessible(true);
|
||||
f.set(service, delegateMock);
|
||||
}
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PublicKey;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
|
||||
|
||||
import stirling.software.common.cluster.KeyValueCache;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
|
||||
/**
|
||||
* Contract test for cluster-wide JWT public-key resolution.
|
||||
*
|
||||
* <p>Setup mirrors a two-node cluster: node A generates a keypair, publishes its public key to the
|
||||
* shared {@link KeyValueCache}, node B has never seen that keyId locally but resolves it from the
|
||||
* cluster cache before falling back to refreshing its own active key.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JwtClusterKeyRotationTest {
|
||||
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
@Mock private ApplicationProperties.Security security;
|
||||
@Mock private ApplicationProperties.Security.Jwt jwtConfig;
|
||||
|
||||
@TempDir Path nodeATempDir;
|
||||
@TempDir Path nodeBTempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
lenient().when(applicationProperties.getSecurity()).thenReturn(security);
|
||||
lenient().when(security.getJwt()).thenReturn(jwtConfig);
|
||||
lenient().when(jwtConfig.isEnableKeystore()).thenReturn(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rotationOnNodeA_publishesPublicKeyToClusterCache() {
|
||||
KeyValueCache shared = mock(KeyValueCache.class);
|
||||
try (MockedStatic<InstallationPathConfig> path = mockStatic(InstallationPathConfig.class)) {
|
||||
path.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(nodeATempDir.toString());
|
||||
|
||||
CacheManager cm = new ConcurrentMapCacheManager("verifyingKeys");
|
||||
KeyPersistenceService nodeA =
|
||||
new KeyPersistenceService(applicationProperties, cm, shared);
|
||||
nodeA.initializeKeystore(); // generates first keypair
|
||||
|
||||
JwtVerificationKey rotated = nodeA.refreshActiveKeyPair();
|
||||
assertNotNull(rotated);
|
||||
verify(shared, times(2))
|
||||
.put(
|
||||
eq(KeyPersistenceService.JWT_PUBKEY_NAMESPACE),
|
||||
anyString(),
|
||||
anyString(),
|
||||
any(Duration.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nodeB_resolvesPeerSignedKey_viaClusterCache() throws NoSuchAlgorithmException {
|
||||
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
|
||||
gen.initialize(2048);
|
||||
KeyPair nodeAPair = gen.generateKeyPair();
|
||||
String peerKeyId = "node-a-key-2026-01-01";
|
||||
String peerEncoded = Base64.getEncoder().encodeToString(nodeAPair.getPublic().getEncoded());
|
||||
|
||||
KeyValueCache shared = mock(KeyValueCache.class);
|
||||
when(shared.get(KeyPersistenceService.JWT_PUBKEY_NAMESPACE, peerKeyId))
|
||||
.thenReturn(Optional.of(peerEncoded));
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> path = mockStatic(InstallationPathConfig.class)) {
|
||||
path.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(nodeBTempDir.toString());
|
||||
|
||||
CacheManager cm = new ConcurrentMapCacheManager("verifyingKeys");
|
||||
KeyPersistenceService nodeB =
|
||||
new KeyPersistenceService(applicationProperties, cm, shared);
|
||||
nodeB.initializeKeystore();
|
||||
|
||||
Optional<PublicKey> resolved = nodeB.resolvePublicKey(peerKeyId);
|
||||
assertTrue(resolved.isPresent(), "node B must resolve peer's keyId from cluster cache");
|
||||
assertEquals(nodeAPair.getPublic(), resolved.get());
|
||||
|
||||
// Peer-fetched keys are not cached locally - each verification re-reads from the
|
||||
// cluster cache so the effective TTL stays aligned with the broadcast TTL.
|
||||
nodeB.resolvePublicKey(peerKeyId);
|
||||
verify(shared, times(2)).get(KeyPersistenceService.JWT_PUBKEY_NAMESPACE, peerKeyId);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvePublicKey_returnsEmpty_whenKeyIdUnknown() {
|
||||
KeyValueCache shared = mock(KeyValueCache.class);
|
||||
when(shared.get(eq(KeyPersistenceService.JWT_PUBKEY_NAMESPACE), anyString()))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> path = mockStatic(InstallationPathConfig.class)) {
|
||||
path.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(nodeBTempDir.toString());
|
||||
|
||||
CacheManager cm = new ConcurrentMapCacheManager("verifyingKeys");
|
||||
KeyPersistenceService node =
|
||||
new KeyPersistenceService(applicationProperties, cm, shared);
|
||||
node.initializeKeystore();
|
||||
|
||||
assertTrue(node.resolvePublicKey("nope-not-here").isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvePublicKey_worksWithoutClusterCache_singleInstanceMode() {
|
||||
try (MockedStatic<InstallationPathConfig> path = mockStatic(InstallationPathConfig.class)) {
|
||||
path.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(nodeATempDir.toString());
|
||||
|
||||
CacheManager cm = new ConcurrentMapCacheManager("verifyingKeys");
|
||||
// Null cluster cache = single-instance install. Must still resolve local keys.
|
||||
KeyPersistenceService node = new KeyPersistenceService(applicationProperties, cm, null);
|
||||
node.initializeKeystore();
|
||||
JwtVerificationKey active = node.getActiveKey();
|
||||
|
||||
Optional<PublicKey> resolved = node.resolvePublicKey(active.getKeyId());
|
||||
assertTrue(resolved.isPresent(), "local keyId must resolve without cluster cache");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishFailure_isNotFatal_keyStillUsableLocally() {
|
||||
KeyValueCache flaky = mock(KeyValueCache.class);
|
||||
doThrow(new RuntimeException("simulated valkey blip"))
|
||||
.when(flaky)
|
||||
.put(anyString(), anyString(), anyString(), any(Duration.class));
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> path = mockStatic(InstallationPathConfig.class)) {
|
||||
path.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(nodeATempDir.toString());
|
||||
|
||||
CacheManager cm = new ConcurrentMapCacheManager("verifyingKeys");
|
||||
KeyPersistenceService node =
|
||||
new KeyPersistenceService(applicationProperties, cm, flaky);
|
||||
node.initializeKeystore();
|
||||
// The broadcast throws, but the keypair MUST still be generated and active.
|
||||
assertNotNull(node.getActiveKey());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvePublicKey_clusterCacheThrows_degradesToEmpty_doesNotPropagate() {
|
||||
// A Valkey blip on the resolve path used to bubble up through JwtService and surface as a
|
||||
// misleading "Claims are empty" log. The caller (JwtService) interprets Optional.empty()
|
||||
// as "unknown keyId" and falls back to its local rotation path, which is the right
|
||||
// behaviour during a cluster-cache outage.
|
||||
KeyValueCache flaky = mock(KeyValueCache.class);
|
||||
when(flaky.get(eq(KeyPersistenceService.JWT_PUBKEY_NAMESPACE), anyString()))
|
||||
.thenThrow(new RuntimeException("simulated valkey outage"));
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> path = mockStatic(InstallationPathConfig.class)) {
|
||||
path.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(nodeBTempDir.toString());
|
||||
|
||||
CacheManager cm = new ConcurrentMapCacheManager("verifyingKeys");
|
||||
KeyPersistenceService node =
|
||||
new KeyPersistenceService(applicationProperties, cm, flaky);
|
||||
node.initializeKeystore();
|
||||
|
||||
// Unknown keyId: local cache + disk miss, falls into the cluster cache, which throws.
|
||||
Optional<PublicKey> resolved = node.resolvePublicKey("peer-key-we-have-never-seen");
|
||||
assertTrue(resolved.isEmpty(), "valkey outage must degrade to Optional.empty()");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeKey_evictsBothLocalAndClusterCaches() {
|
||||
KeyValueCache shared = mock(KeyValueCache.class);
|
||||
try (MockedStatic<InstallationPathConfig> path = mockStatic(InstallationPathConfig.class)) {
|
||||
path.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(nodeATempDir.toString());
|
||||
|
||||
CacheManager cm = new ConcurrentMapCacheManager("verifyingKeys");
|
||||
KeyPersistenceService node =
|
||||
new KeyPersistenceService(applicationProperties, cm, shared);
|
||||
node.initializeKeystore();
|
||||
JwtVerificationKey active = node.getActiveKey();
|
||||
|
||||
node.removeKey(active.getKeyId());
|
||||
|
||||
verify(shared).evict(KeyPersistenceService.JWT_PUBKEY_NAMESPACE, active.getKeyId());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeKey_clusterEvictFailure_isNotFatal() {
|
||||
KeyValueCache flaky = mock(KeyValueCache.class);
|
||||
doThrow(new RuntimeException("simulated valkey blip"))
|
||||
.when(flaky)
|
||||
.evict(anyString(), anyString());
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> path = mockStatic(InstallationPathConfig.class)) {
|
||||
path.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(nodeATempDir.toString());
|
||||
|
||||
CacheManager cm = new ConcurrentMapCacheManager("verifyingKeys");
|
||||
KeyPersistenceService node =
|
||||
new KeyPersistenceService(applicationProperties, cm, flaky);
|
||||
node.initializeKeystore();
|
||||
JwtVerificationKey active = node.getActiveKey();
|
||||
|
||||
// Must not throw.
|
||||
node.removeKey(active.getKeyId());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void localGetKeyPair_recoversFromClusterCacheMiss_withoutRotation() {
|
||||
KeyValueCache cluster = mock(KeyValueCache.class);
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> path = mockStatic(InstallationPathConfig.class)) {
|
||||
path.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(nodeATempDir.toString());
|
||||
|
||||
CacheManager cm = new ConcurrentMapCacheManager("verifyingKeys");
|
||||
KeyPersistenceService node =
|
||||
new KeyPersistenceService(applicationProperties, cm, cluster);
|
||||
node.initializeKeystore();
|
||||
|
||||
JwtVerificationKey active = node.getActiveKey();
|
||||
assertNotNull(active);
|
||||
|
||||
assertTrue(node.getKeyPair(active.getKeyId()).isPresent());
|
||||
verify(cluster, never())
|
||||
.get(eq(KeyPersistenceService.JWT_PUBKEY_NAMESPACE), anyString());
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-16
@@ -10,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.atLeast;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -294,35 +295,47 @@ class JwtServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTokenVerificationFallsBackToActiveKeyWhenKeyIdNotFound() throws Exception {
|
||||
void tokenVerification_preferringLocalKeyPair_overRotation() throws Exception {
|
||||
// First getKeyPair misses (cold verifyingKeyCache), third succeeds; rotation must not fire.
|
||||
String username = "testuser";
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
|
||||
// First, generate a token successfully
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.getKeyPair("test-key-id"))
|
||||
.thenReturn(Optional.of(testKeyPair)) // signing
|
||||
.thenReturn(Optional.empty()) // first validation lookup: miss
|
||||
.thenReturn(Optional.of(testKeyPair)); // recovery lookup: hit
|
||||
when(keystoreService.resolvePublicKey("test-key-id")).thenReturn(Optional.empty());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
|
||||
String token = jwtService.generateToken(authentication, claims);
|
||||
assertDoesNotThrow(() -> jwtService.validateToken(token));
|
||||
verify(keystoreService, never()).refreshActiveKeyPair();
|
||||
}
|
||||
|
||||
// Now mock the scenario for validation - key not found, but fallback works
|
||||
// Create a fallback key pair that can be used
|
||||
JwtVerificationKey fallbackKey =
|
||||
@Test
|
||||
void rotationStillHappens_whenKeyGenuinelyMissingEverywhere() throws Exception {
|
||||
String username = "testuser";
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
|
||||
JwtVerificationKey rotatedKey =
|
||||
new JwtVerificationKey(
|
||||
"fallback-key",
|
||||
"rotated-key",
|
||||
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()));
|
||||
|
||||
// Mock the specific key lookup to fail, but the active key should work
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.empty());
|
||||
when(keystoreService.refreshActiveKeyPair()).thenReturn(fallbackKey);
|
||||
when(keystoreService.getKeyPair("fallback-key")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id"))
|
||||
.thenReturn(Optional.of(testKeyPair)) // signing
|
||||
.thenReturn(Optional.empty()); // all later lookups miss - key is gone
|
||||
when(keystoreService.resolvePublicKey("test-key-id")).thenReturn(Optional.empty());
|
||||
when(keystoreService.refreshActiveKeyPair()).thenReturn(rotatedKey);
|
||||
when(keystoreService.getKeyPair("rotated-key")).thenReturn(Optional.of(testKeyPair));
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
|
||||
// Should still work by falling back to the active keypair
|
||||
String token = jwtService.generateToken(authentication, claims);
|
||||
assertDoesNotThrow(() -> jwtService.validateToken(token));
|
||||
assertEquals(username, jwtService.extractUsername(token));
|
||||
|
||||
// Verify fallback logic was used
|
||||
verify(keystoreService, atLeast(1)).getActiveKey();
|
||||
verify(keystoreService, atLeast(1)).refreshActiveKeyPair();
|
||||
}
|
||||
}
|
||||
|
||||
+38
-8
@@ -71,7 +71,8 @@ class KeyPersistenceServiceInterfaceTest {
|
||||
mockedStatic
|
||||
.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(tempDir.toString());
|
||||
keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager);
|
||||
keyPersistenceService =
|
||||
new KeyPersistenceService(applicationProperties, cacheManager, null);
|
||||
|
||||
assertEquals(keystoreEnabled, keyPersistenceService.isKeystoreEnabled());
|
||||
}
|
||||
@@ -84,7 +85,8 @@ class KeyPersistenceServiceInterfaceTest {
|
||||
mockedStatic
|
||||
.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(tempDir.toString());
|
||||
keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager);
|
||||
keyPersistenceService =
|
||||
new KeyPersistenceService(applicationProperties, cacheManager, null);
|
||||
keyPersistenceService.initializeKeystore();
|
||||
|
||||
JwtVerificationKey result = keyPersistenceService.getActiveKey();
|
||||
@@ -113,7 +115,8 @@ class KeyPersistenceServiceInterfaceTest {
|
||||
mockedStatic
|
||||
.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(tempDir.toString());
|
||||
keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager);
|
||||
keyPersistenceService =
|
||||
new KeyPersistenceService(applicationProperties, cacheManager, null);
|
||||
keyPersistenceService.initializeKeystore();
|
||||
|
||||
JwtVerificationKey result = keyPersistenceService.getActiveKey();
|
||||
@@ -141,7 +144,8 @@ class KeyPersistenceServiceInterfaceTest {
|
||||
mockedStatic
|
||||
.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(tempDir.toString());
|
||||
keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager);
|
||||
keyPersistenceService =
|
||||
new KeyPersistenceService(applicationProperties, cacheManager, null);
|
||||
|
||||
keyPersistenceService
|
||||
.getClass()
|
||||
@@ -167,7 +171,8 @@ class KeyPersistenceServiceInterfaceTest {
|
||||
mockedStatic
|
||||
.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(tempDir.toString());
|
||||
keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager);
|
||||
keyPersistenceService =
|
||||
new KeyPersistenceService(applicationProperties, cacheManager, null);
|
||||
|
||||
Optional<KeyPair> result = keyPersistenceService.getKeyPair(keyId);
|
||||
|
||||
@@ -184,7 +189,8 @@ class KeyPersistenceServiceInterfaceTest {
|
||||
mockedStatic
|
||||
.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(tempDir.toString());
|
||||
keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager);
|
||||
keyPersistenceService =
|
||||
new KeyPersistenceService(applicationProperties, cacheManager, null);
|
||||
|
||||
Optional<KeyPair> result = keyPersistenceService.getKeyPair("any-key");
|
||||
|
||||
@@ -199,7 +205,8 @@ class KeyPersistenceServiceInterfaceTest {
|
||||
mockedStatic
|
||||
.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(tempDir.toString());
|
||||
keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager);
|
||||
keyPersistenceService =
|
||||
new KeyPersistenceService(applicationProperties, cacheManager, null);
|
||||
keyPersistenceService.initializeKeystore();
|
||||
|
||||
assertTrue(Files.exists(tempDir));
|
||||
@@ -207,6 +214,28 @@ class KeyPersistenceServiceInterfaceTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeAtomically_writesFinalFile_andLeavesNoTempFile() throws IOException {
|
||||
Path target = tempDir.resolve("jwt-key-test.key");
|
||||
KeyPersistenceService.writeAtomically(target, "payload-bytes");
|
||||
|
||||
assertTrue(Files.exists(target), "final file must exist after atomic write");
|
||||
assertEquals("payload-bytes", Files.readString(target));
|
||||
assertFalse(
|
||||
Files.exists(target.resolveSibling("jwt-key-test.key.tmp")),
|
||||
"tmp file must not survive a successful move");
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeAtomically_overwritesExistingFinalFile() throws IOException {
|
||||
Path target = tempDir.resolve("jwt-key-overwrite.key");
|
||||
Files.writeString(target, "old-content");
|
||||
|
||||
KeyPersistenceService.writeAtomically(target, "new-content");
|
||||
|
||||
assertEquals("new-content", Files.readString(target));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoadExistingKeypairWithMissingPrivateKeyFile() throws Exception {
|
||||
String keyId = "test-key-missing-file";
|
||||
@@ -220,7 +249,8 @@ class KeyPersistenceServiceInterfaceTest {
|
||||
mockedStatic
|
||||
.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(tempDir.toString());
|
||||
keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager);
|
||||
keyPersistenceService =
|
||||
new KeyPersistenceService(applicationProperties, cacheManager, null);
|
||||
keyPersistenceService.initializeKeystore();
|
||||
|
||||
JwtVerificationKey result = keyPersistenceService.getActiveKey();
|
||||
|
||||
+23
@@ -290,4 +290,27 @@ class UserServiceTest {
|
||||
verify(userRepository, never()).delete(any());
|
||||
verify(workflowSessionRepository, never()).findByOwnerOrderByCreatedAtDesc(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateApiKeyForUser_acceptsExactMatch_rejectsWrongKey_rejectsNullKey() {
|
||||
User user = new User();
|
||||
user.setUsername("svc");
|
||||
user.setApiKey("real-correct-api-key-xyzzy");
|
||||
when(userRepository.findByUsernameIgnoreCase("svc")).thenReturn(Optional.of(user));
|
||||
|
||||
assertTrue(userService.validateApiKeyForUser("svc", "real-correct-api-key-xyzzy"));
|
||||
assertFalse(userService.validateApiKeyForUser("svc", "real-incorrect-api-key-vvv"));
|
||||
assertFalse(userService.validateApiKeyForUser("svc", "short"));
|
||||
assertFalse(userService.validateApiKeyForUser("svc", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateApiKeyForUser_rejectsWhenUserHasNullStoredKey() {
|
||||
User user = new User();
|
||||
user.setUsername("svc");
|
||||
user.setApiKey(null);
|
||||
when(userRepository.findByUsernameIgnoreCase("svc")).thenReturn(Optional.of(user));
|
||||
|
||||
assertFalse(userService.validateApiKeyForUser("svc", "any-key"));
|
||||
}
|
||||
}
|
||||
|
||||
-250
@@ -1,250 +0,0 @@
|
||||
package stirling.software.proprietary.storage.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
|
||||
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
|
||||
|
||||
class ClusterStorageGateTest {
|
||||
|
||||
@Test
|
||||
void clusterDisabled_localStorage_passes() {
|
||||
ClusterStorageGate gate = newGate(false, true, "local", "local");
|
||||
assertThatCode(gate::validate).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterDisabled_s3Storage_passes() {
|
||||
ClusterStorageGate gate = newGate(false, true, "s3", "local");
|
||||
assertThatCode(gate::validate).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterEnabled_storageDisabled_butArtifactStoreLocal_fails() {
|
||||
ClusterStorageGate gate = newGate(true, false, "local", "local");
|
||||
assertThatThrownBy(gate::validate)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("cluster.artifactStore=local");
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterEnabled_storageDisabled_artifactStoreS3_passes() {
|
||||
ClusterStorageGate gate = newGate(true, false, "local", "s3");
|
||||
assertThatCode(gate::validate).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterEnabled_localStorage_fails() {
|
||||
ClusterStorageGate gate = newGate(true, true, "local", "s3");
|
||||
assertThatThrownBy(gate::validate)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("storage.provider=local")
|
||||
.hasMessageContaining("storage.provider=s3")
|
||||
.hasMessageContaining("storage.provider=database");
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterEnabled_localStorage_caseInsensitive_fails() {
|
||||
ClusterStorageGate gate = newGate(true, true, "LOCAL", "s3");
|
||||
assertThatThrownBy(gate::validate).isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterEnabled_nullProvider_treatedAsLocal_fails() {
|
||||
ClusterStorageGate gate = newGate(true, true, null, "s3");
|
||||
assertThatThrownBy(gate::validate)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("storage.provider=local");
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterEnabled_s3Storage_andArtifactStoreS3_passes() {
|
||||
ClusterStorageGate gate = newGate(true, true, "s3", "s3");
|
||||
assertThatCode(gate::validate).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterEnabled_databaseStorage_andArtifactStoreS3_passes() {
|
||||
ClusterStorageGate gate = newGate(true, true, "database", "s3");
|
||||
assertThatCode(gate::validate).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterEnabled_s3Storage_butLocalArtifactStore_fails() {
|
||||
ClusterStorageGate gate = newGate(true, true, "s3", "local");
|
||||
assertThatThrownBy(gate::validate)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("cluster.artifactStore=local");
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterEnabled_localArtifactStore_caseInsensitive_fails() {
|
||||
ClusterStorageGate gate = newGate(true, true, "s3", "LOCAL");
|
||||
assertThatThrownBy(gate::validate).isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterEnabled_nullArtifactStore_treatedAsLocal_fails() {
|
||||
ClusterStorageGate gate = newGate(true, true, "s3", null);
|
||||
assertThatThrownBy(gate::validate)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("cluster.artifactStore=local");
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterEnabled_nullStorageObject_passesProviderCheck_butArtifactStoreStillEvaluated() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.setStorage(null);
|
||||
ClusterStorageGate gate = new ClusterStorageGate(props, mockLicenseChecker(License.SERVER));
|
||||
setClusterEnabled(gate, true);
|
||||
setClusterArtifactStore(gate, "s3");
|
||||
assertThatCode(gate::validate).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// ----- License gating for premium storage backends -----
|
||||
|
||||
@Test
|
||||
void storageProviderS3_withoutProLicense_throws() {
|
||||
ClusterStorageGate gate = newGate(false, true, "s3", "local", License.NORMAL);
|
||||
assertThatThrownBy(gate::validate)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("storage.provider=s3 requires a Pro or Enterprise license");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storageProviderDatabase_withoutProLicense_throws() {
|
||||
ClusterStorageGate gate = newGate(false, true, "database", "local", License.NORMAL);
|
||||
assertThatThrownBy(gate::validate)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining(
|
||||
"storage.provider=database requires a Pro or Enterprise license");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storageProviderS3_withServerLicense_passes() {
|
||||
ClusterStorageGate gate = newGate(false, true, "s3", "local", License.SERVER);
|
||||
assertThatCode(gate::validate).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void storageProviderS3_withEnterpriseLicense_passes() {
|
||||
ClusterStorageGate gate = newGate(false, true, "s3", "local", License.ENTERPRISE);
|
||||
assertThatCode(gate::validate).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void storageProviderDatabase_withServerLicense_passes() {
|
||||
ClusterStorageGate gate = newGate(false, true, "database", "local", License.SERVER);
|
||||
assertThatCode(gate::validate).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterArtifactStoreS3_withoutProLicense_throws() {
|
||||
ClusterStorageGate gate = newGate(false, false, "local", "s3", License.NORMAL);
|
||||
assertThatThrownBy(gate::validate)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining(
|
||||
"cluster.artifactStore=s3 requires a Pro or Enterprise license");
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterArtifactStoreS3_withServerLicense_passes() {
|
||||
ClusterStorageGate gate = newGate(false, false, "local", "s3", License.SERVER);
|
||||
assertThatCode(gate::validate).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void localOnly_normalLicense_passes_licenseNotChecked() {
|
||||
ClusterStorageGate gate = newGate(false, true, "local", "local", License.NORMAL);
|
||||
assertThatCode(gate::validate).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void storageDisabled_butArtifactStoreS3_withoutLicense_stillThrows() {
|
||||
ClusterStorageGate gate = newGate(false, false, "local", "s3", License.NORMAL);
|
||||
assertThatThrownBy(gate::validate)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("cluster.artifactStore=s3");
|
||||
}
|
||||
|
||||
private static ClusterStorageGate newGate(
|
||||
boolean clusterEnabled,
|
||||
boolean storageEnabled,
|
||||
String provider,
|
||||
String clusterArtifactStore) {
|
||||
// Default to a SERVER license so existing tests (which assert clustering / artifact-store
|
||||
// rules independently of license) continue to pass. License-specific tests below build
|
||||
// gates with explicit license tiers.
|
||||
return newGate(
|
||||
clusterEnabled, storageEnabled, provider, clusterArtifactStore, License.SERVER);
|
||||
}
|
||||
|
||||
private static ClusterStorageGate newGate(
|
||||
boolean clusterEnabled,
|
||||
boolean storageEnabled,
|
||||
String provider,
|
||||
String clusterArtifactStore,
|
||||
License license) {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
ApplicationProperties.Storage storage = new ApplicationProperties.Storage();
|
||||
storage.setEnabled(storageEnabled);
|
||||
storage.setProvider(provider);
|
||||
props.setStorage(storage);
|
||||
LicenseKeyChecker checker = mockLicenseChecker(license);
|
||||
ClusterStorageGate gate = new ClusterStorageGate(props, checker);
|
||||
setClusterEnabled(gate, clusterEnabled);
|
||||
setClusterArtifactStore(gate, clusterArtifactStore);
|
||||
return gate;
|
||||
}
|
||||
|
||||
private static LicenseKeyChecker mockLicenseChecker(License license) {
|
||||
LicenseKeyChecker checker = mock(LicenseKeyChecker.class);
|
||||
when(checker.getPremiumLicenseEnabledResult()).thenReturn(license);
|
||||
if (license == License.SERVER || license == License.ENTERPRISE) {
|
||||
doNothing().when(checker).requireProOrEnterprise(anyString());
|
||||
} else {
|
||||
// Mirror real LicenseKeyChecker.requireProOrEnterprise so message assertions match.
|
||||
org.mockito.Mockito.doAnswer(
|
||||
inv -> {
|
||||
throw new IllegalStateException(
|
||||
inv.getArgument(0)
|
||||
+ " requires a Pro or Enterprise license");
|
||||
})
|
||||
.when(checker)
|
||||
.requireProOrEnterprise(anyString());
|
||||
}
|
||||
return checker;
|
||||
}
|
||||
|
||||
private static void setClusterEnabled(ClusterStorageGate gate, boolean enabled) {
|
||||
try {
|
||||
Field f = ClusterStorageGate.class.getDeclaredField("clusterEnabled");
|
||||
f.setAccessible(true);
|
||||
f.setBoolean(gate, enabled);
|
||||
assertThat(f.getBoolean(gate)).isEqualTo(enabled);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new AssertionError("Failed to set clusterEnabled via reflection", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void setClusterArtifactStore(ClusterStorageGate gate, String value) {
|
||||
try {
|
||||
Field f = ClusterStorageGate.class.getDeclaredField("clusterArtifactStore");
|
||||
f.setAccessible(true);
|
||||
f.set(gate, value);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new AssertionError("Failed to set clusterArtifactStore via reflection", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user