mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
529a4004ed | ||
|
|
732ef18ae5 | ||
|
|
f7a2c626c9 | ||
|
|
caeca0b88a | ||
|
|
c93feb5dfc | ||
|
|
f945cc7dc6 | ||
|
|
72b7892312 | ||
|
|
353df7a647 | ||
|
|
0d75715af2 | ||
|
|
b44202185a | ||
|
|
49c1e75ced | ||
|
|
826e487f00 | ||
|
|
bcad2cd486 |
+62
-3
@@ -40,12 +40,15 @@ tasks:
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
|
||||
# Set by dev:linked. Inline rather than in `env:` so an empty value emits nothing
|
||||
# and cannot blank the committed default.
|
||||
ACCOUNT_LINK_SAAS_BASE_URL: '{{.ACCOUNT_LINK_SAAS_BASE_URL | default ""}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
cmds:
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
platforms: [windows]
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:bundled:
|
||||
@@ -84,6 +87,8 @@ tasks:
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
APP_BASE_URL: '{{.APP_BASE_URL}}'
|
||||
BASE_PATH: '{{.BASE_PATH}}'
|
||||
|
||||
staging:saas:
|
||||
desc: "Start SaaS backend against the shared v3 staging project"
|
||||
@@ -95,10 +100,47 @@ tasks:
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
APP_BASE_URL: '{{.APP_BASE_URL}}'
|
||||
BASE_PATH: '{{.BASE_PATH}}'
|
||||
|
||||
dev:linked:
|
||||
desc: "Self-hosted backend linked to a locally running SaaS backend (see task linked:*)"
|
||||
ignore_error: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
SAAS_BASE_URL: '{{.SAAS_BASE_URL | default "http://localhost:8081"}}'
|
||||
cmds:
|
||||
- 'echo ">> self-hosted :{{.PORT}} linking to SaaS at {{.SAAS_BASE_URL}}"'
|
||||
# The two backends run different STIRLING_FLAVOURs, which are different Gradle
|
||||
# project graphs sharing one build/ tree. Waiting avoids overlapping builds; it
|
||||
# does not make the sharing safe, so avoid rebuilding one while the other runs.
|
||||
- cmd: |
|
||||
n=0
|
||||
while [ "$n" -lt 150 ]; do
|
||||
if curl -s -m 2 "{{.SAAS_BASE_URL}}" >/dev/null 2>&1; then
|
||||
echo ">> SaaS backend is up, starting self-hosted"
|
||||
break
|
||||
fi
|
||||
n=$((n + 1))
|
||||
{{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}}
|
||||
done
|
||||
if [ "$n" -ge 150 ]; then
|
||||
echo ">> SaaS backend never answered; starting anyway"
|
||||
fi
|
||||
- task: dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.PORT}}'
|
||||
ACCOUNT_LINK_SAAS_BASE_URL: '{{.SAAS_BASE_URL}}'
|
||||
|
||||
_run:saas:
|
||||
internal: true
|
||||
dotenv: ['app/.env.saas.local', 'app/.env.saas']
|
||||
# The frontend files are here only for RUN_SUBPATH, which the authorize URL needs.
|
||||
# Last, because dotenv is set-if-absent: app/* still decides everything else.
|
||||
dotenv:
|
||||
- 'app/.env.saas.local'
|
||||
- 'app/.env.saas'
|
||||
- 'frontend/editor/.env.saas.local'
|
||||
- 'frontend/editor/.env.saas'
|
||||
ignore_error: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
@@ -111,12 +153,29 @@ tasks:
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
# Empty is the same as unset: the property defaults to empty and is blank-checked.
|
||||
APP_BASE_URL: '{{.APP_BASE_URL | default ""}}'
|
||||
# Relocates configs/pipeline/logs, for a second backend in the same directory.
|
||||
# Empty is the same as unset: the reader blank-checks it.
|
||||
BASE_PATH: '{{.BASE_PATH | default ""}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
STIRLING_FLAVOR: saas
|
||||
STIRLING_BASE_PATH: '{{.BASE_PATH}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
# Appends RUN_SUBPATH: the approval page is at <base>/link, so a subpath build
|
||||
# serves it at <base>/app/link. An explicit value still wins.
|
||||
SYSTEM_FRONTENDURL:
|
||||
sh: |
|
||||
if [ -n "${SYSTEM_FRONTENDURL:-}" ]; then
|
||||
echo "${SYSTEM_FRONTENDURL}"
|
||||
elif [ -n "{{.APP_BASE_URL}}" ] && [ -n "${RUN_SUBPATH:-}" ]; then
|
||||
echo "{{.APP_BASE_URL}}/${RUN_SUBPATH}"
|
||||
else
|
||||
echo "{{.APP_BASE_URL}}"
|
||||
fi
|
||||
cmds:
|
||||
# PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile
|
||||
# against SAAS_DB_* (production).
|
||||
|
||||
+13
-3
@@ -121,17 +121,17 @@ tasks:
|
||||
sh: |
|
||||
case "${SAAS_ENV:-dev}" in
|
||||
staging) ref="${SAAS_STAGING_PROJECT_REF:?set it in app/.env.saas.local}" ;;
|
||||
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or run task staging:saas}" ;;
|
||||
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;;
|
||||
esac
|
||||
echo "https://${ref}.supabase.co"
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY:
|
||||
sh: |
|
||||
case "${SAAS_ENV:-dev}" in
|
||||
staging) echo "${SAAS_STAGING_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
|
||||
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
|
||||
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;;
|
||||
esac
|
||||
cmds:
|
||||
- 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"'
|
||||
- 'echo ">> frontend {{.SAAS_ENV}}: Supabase $VITE_SUPABASE_URL, backend $BACKEND_URL"'
|
||||
- npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}}
|
||||
|
||||
dev:
|
||||
@@ -173,6 +173,16 @@ tasks:
|
||||
OPEN: '{{.OPEN}}'
|
||||
SAAS_ENV: '{{.SAAS_ENV}}'
|
||||
|
||||
staging:saas:
|
||||
desc: "Start frontend dev server against the shared v3 staging project"
|
||||
cmds:
|
||||
- task: dev:saas
|
||||
vars:
|
||||
SAAS_ENV: staging
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
OPEN: '{{.OPEN}}'
|
||||
|
||||
dev:desktop:
|
||||
desc: "Start frontend dev server in desktop mode"
|
||||
deps:
|
||||
|
||||
@@ -121,6 +121,92 @@ tasks:
|
||||
cmds:
|
||||
- task: dev:_all
|
||||
|
||||
# No engine: linking never calls it.
|
||||
linked:staging:
|
||||
desc: "SaaS on the shared v3 project + a self-hosted instance linked to it"
|
||||
cmds:
|
||||
- task: linked:_all
|
||||
vars: { SAAS_ENV: staging }
|
||||
|
||||
linked:dev:
|
||||
desc: "SaaS on the current PR's preview branch + a self-hosted instance linked to it"
|
||||
cmds:
|
||||
- task: linked:_all
|
||||
vars: { SAAS_ENV: dev }
|
||||
|
||||
linked:_all:
|
||||
internal: true
|
||||
vars:
|
||||
SAAS_ENV: '{{.SAAS_ENV | default "staging"}}'
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8081 5174 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8081 5174 8080 5173{{end}}'
|
||||
SAAS_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
SAAS_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
APP_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
APP_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 3}}'
|
||||
deps:
|
||||
# APP_BASE_URL is the SaaS *frontend*: the approval page is served by vite, not
|
||||
# by the API. BASE_PATH moves this backend's configs/pipeline aside so it does not
|
||||
# race the self-hosted one, which keeps ./configs and its existing database.
|
||||
- task: 'backend:{{.SAAS_ENV}}:saas'
|
||||
vars:
|
||||
PORT: '{{.SAAS_BACKEND_PORT}}'
|
||||
APP_BASE_URL: 'http://localhost:{{.SAAS_FRONTEND_PORT}}'
|
||||
BASE_PATH: 'tmp/linked-saas'
|
||||
- task: frontend:dev:saas
|
||||
vars:
|
||||
PORT: '{{.SAAS_FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}'
|
||||
SAAS_ENV: '{{.SAAS_ENV}}'
|
||||
- task: backend:dev:linked
|
||||
vars:
|
||||
PORT: '{{.APP_BACKEND_PORT}}'
|
||||
SAAS_BASE_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}'
|
||||
- task: frontend:dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.APP_FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.APP_BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
- task: linked:_ready
|
||||
vars:
|
||||
SAAS_BACKEND_PORT: '{{.SAAS_BACKEND_PORT}}'
|
||||
SAAS_FRONTEND_PORT: '{{.SAAS_FRONTEND_PORT}}'
|
||||
APP_BACKEND_PORT: '{{.APP_BACKEND_PORT}}'
|
||||
APP_FRONTEND_PORT: '{{.APP_FRONTEND_PORT}}'
|
||||
|
||||
# Waits for all four to answer, then prints where they landed.
|
||||
linked:_ready:
|
||||
internal: true
|
||||
cmds:
|
||||
- cmd: |
|
||||
n=0
|
||||
ok=0
|
||||
while [ "$n" -lt 150 ]; do
|
||||
ok=1
|
||||
for u in "http://localhost:{{.SAAS_BACKEND_PORT}}" \
|
||||
"http://localhost:{{.SAAS_FRONTEND_PORT}}" \
|
||||
"http://localhost:{{.APP_BACKEND_PORT}}" \
|
||||
"http://localhost:{{.APP_FRONTEND_PORT}}"; do
|
||||
# Not -o /dev/null: Windows curl.exe treats it as a real path and exits 23.
|
||||
curl -s -m 2 "$u" >/dev/null 2>&1 || ok=0
|
||||
done
|
||||
if [ "$ok" = 1 ]; then break; fi
|
||||
n=$((n + 1))
|
||||
# `sleep` is a binary, not a builtin, and Windows has none.
|
||||
{{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}}
|
||||
done
|
||||
echo ""
|
||||
if [ "$ok" = 1 ]; then
|
||||
echo ">> all four answering"
|
||||
else
|
||||
echo ">> still waiting on one or more after 5 minutes; addresses below anyway"
|
||||
fi
|
||||
echo ">> self-hosted UI http://localhost:{{.APP_FRONTEND_PORT}}/processor"
|
||||
echo ">> self-hosted api http://localhost:{{.APP_BACKEND_PORT}}"
|
||||
echo ">> saas UI http://localhost:{{.SAAS_FRONTEND_PORT}}"
|
||||
echo ">> saas api http://localhost:{{.SAAS_BACKEND_PORT}}"
|
||||
echo ""
|
||||
|
||||
dev:_all:
|
||||
internal: true
|
||||
vars:
|
||||
|
||||
@@ -186,7 +186,7 @@ system:
|
||||
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
|
||||
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). WARNING: leaving this empty falls back to allowing ALL origins (with credentials), it does NOT disable CORS. Set explicit origins to lock it down.
|
||||
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
|
||||
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
|
||||
frontendUrl: "" # Base URL of the web app, as a browser reaches it (e.g. 'https://app.example.com', or 'https://example.com/app' if served under a base path). Optional - if not set, will use backendUrl. Used for any link handed to a browser: invite emails, share links, mobile QR codes, and the account-link handshake.
|
||||
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
|
||||
enableMobileSignature: true # Enable drawing signatures on a phone via QR code from the Sign tool. Requires frontendUrl to be configured.
|
||||
mobileScannerSettings:
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{
|
||||
"moduleName": "ch.qos.logback:logback-classic",
|
||||
"moduleUrl": "http://www.qos.ch",
|
||||
"moduleVersion": "1.6.1",
|
||||
"moduleVersion": "1.6.3",
|
||||
"moduleLicense": "LGPL-2.1-only",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
|
||||
},
|
||||
@@ -31,7 +31,7 @@
|
||||
{
|
||||
"moduleName": "ch.qos.logback:logback-core",
|
||||
"moduleUrl": "http://www.qos.ch",
|
||||
"moduleVersion": "1.6.1",
|
||||
"moduleVersion": "1.6.3",
|
||||
"moduleLicense": "LGPL-2.1-only",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
|
||||
},
|
||||
@@ -1064,21 +1064,14 @@
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-annotations-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations",
|
||||
"moduleVersion": "2.2.46",
|
||||
"moduleVersion": "2.2.47",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-annotations-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations",
|
||||
"moduleVersion": "2.2.47",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-core-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core",
|
||||
"moduleVersion": "2.2.46",
|
||||
"moduleVersion": "2.2.53",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
@@ -1090,9 +1083,9 @@
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-models-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models",
|
||||
"moduleVersion": "2.2.46",
|
||||
"moduleName": "io.swagger.core.v3:swagger-core-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core",
|
||||
"moduleVersion": "2.2.53",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
@@ -1103,6 +1096,13 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-models-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models",
|
||||
"moduleVersion": "2.2.53",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "jakarta.activation:jakarta.activation-api",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
@@ -2304,7 +2304,7 @@
|
||||
},
|
||||
{
|
||||
"moduleName": "org.simplejavamail:core-module",
|
||||
"moduleVersion": "9.3.1",
|
||||
"moduleVersion": "9.3.2",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -2317,13 +2317,13 @@
|
||||
},
|
||||
{
|
||||
"moduleName": "org.simplejavamail:outlook-module",
|
||||
"moduleVersion": "9.3.1",
|
||||
"moduleVersion": "9.3.2",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.simplejavamail:simple-java-mail",
|
||||
"moduleVersion": "9.3.1",
|
||||
"moduleVersion": "9.3.2",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -2343,10 +2343,10 @@
|
||||
},
|
||||
{
|
||||
"moduleName": "org.snakeyaml:snakeyaml-engine",
|
||||
"moduleUrl": "https://bitbucket.org/snakeyaml/snakeyaml-engine",
|
||||
"moduleVersion": "3.0.1",
|
||||
"moduleUrl": "https://codeberg.org/snakeyaml/snakeyaml-engine",
|
||||
"moduleVersion": "3.1.1",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springdoc:springdoc-openapi-starter-common",
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
Bag Attributes
|
||||
friendlyName: alias
|
||||
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
|
||||
subject=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
|
||||
issuer=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
Bag Attributes
|
||||
friendlyName: alias
|
||||
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
|
||||
subject=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
|
||||
issuer=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDiTCCAnGgAwIBAgIUdWDUiSWDll+owMQEzypIuChp+bcwDQYJKoZIhvcNAQEL
|
||||
MIIDizCCAnOgAwIBAgIUZMcjBbPlADpy5PssUsPlPM/h7dcwDQYJKoZIhvcNAQEL
|
||||
BQAwVDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjENMAsG
|
||||
A1UECgwEVGVzdDENMAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAeFw0yNTA4
|
||||
MjYwNzQxMTBaFw0yNjA4MjYwNzQxMTBaMFQxCzAJBgNVBAYTAlVTMQswCQYDVQQI
|
||||
DAJDQTELMAkGA1UEBwwCU0YxDTALBgNVBAoMBFRlc3QxDTALBgNVBAsMBFRlc3Qx
|
||||
DTALBgNVBAMMBFRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDM
|
||||
SfspXLx1WAKSo3AfDYIJAyeSrqFcTsPoNBEvT2U1b8w+SCTw4xR5sC3pNenbiEQ7
|
||||
4sI60hgURtOMOAt+iKvfI0A/9N8/wYadXUyis4qGZPkM/F6H5cBF9VaYisGptY2w
|
||||
ad9X8XcZgZFABYA5O50Jb5nbUM8fPwDYz2fISIejIpW36y+ApFsotJQCaISe4UWb
|
||||
K7bwW4UycghYh7AqfH/1OvgR35gGeL7S+SC0F+CZqGECgansFOh/yYL6VoatoggV
|
||||
oZxjIQblmuSrLtfwN1S7ngn85k3NFMBHm1ehMOHabx5G58Wg05/0mBK8bIrwjrNp
|
||||
Wzomit8BQJ7eIYUikZfVAgMBAAGjUzBRMB0GA1UdDgQWBBRm6hGFGnC1dxipumf/
|
||||
6ROdNE6/YDAfBgNVHSMEGDAWgBRm6hGFGnC1dxipumf/6ROdNE6/YDAPBgNVHRMB
|
||||
Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB66MPy5kZlSlBgsK4HtB1LSr3M
|
||||
dmBWbnQQMq9rmD9AIBQV/shiIjMXGRGnt9zaB0Gg9M39iEvISE6ByMpaDQqV0Md5
|
||||
9y4XJu0rg/aMXLaHOGDAWJsb7nCGDt12cWdgn1Ni2mmXUHv4SJCRXNQF7mSgIr+p
|
||||
Fvd1ljyvzu/iig8qxrcuWoZvY677p3yen4dN8ocgi8Df3KjduGbsTjFAESYqqNQC
|
||||
f+bvypQfhHjxdvz5W3Lpk2swUufqOvhO2b6+cshYJX98qLU8mhai/rOnYkHE7haq
|
||||
WDH6XEthnVGtk2VJ4XFDbz+FID440DPzy5u/1OZw2Mcoyp6y7rZDKC/D0Uvh
|
||||
A1UECgwEVGVzdDENMAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAgFw0yNTAx
|
||||
MDEwMDAwMDBaGA8yMTI1MDEwMTAwMDAwMFowVDELMAkGA1UEBhMCVVMxCzAJBgNV
|
||||
BAgMAkNBMQswCQYDVQQHDAJTRjENMAsGA1UECgwEVGVzdDENMAsGA1UECwwEVGVz
|
||||
dDENMAsGA1UEAwwEVGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
|
||||
AMmEw2bpAoPnrUWkydKGmvDl0bHaCMoSY9yK2b/JL1xaTRwGT4H7DOuO/0Uu6/BV
|
||||
c93UEO0eHf1mt1kkYaRSPyOQLXF2QzRDkiW78c/xvqX+DgmfB7BFNrFSP+CRabdH
|
||||
wvepLUFtXJ6WwvWXjyvDXn8wEAirfETMdU8OXlPaJwS6cbQawYuB6GG5Z1ulxw6k
|
||||
GRi5hnq7PFJBxz1pg6Xx6pwKnaaNemW4Gp2B90St9N5yu7yV6V6XON84ZdohfXSQ
|
||||
livH3UTdkrpe+MO2m3CaAA19zlIxM6OIhkuo8r5GEPxoXCykPbgGMRFWPt0GNC3/
|
||||
AxZofAJg8atqy4peR8XL060CAwEAAaNTMFEwHQYDVR0OBBYEFDULr71QH24KKgNi
|
||||
2fyM6W9qJnzsMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUNQuvvVAfbgoq
|
||||
A2LZ/Izpb2omfOwwDQYJKoZIhvcNAQELBQADggEBAASZENsvvyxygLjE913BBLd2
|
||||
73unxWNSDXakT4xssDEysf//4nGPGo57KjUGFU6M64IVUmhPRTT3aYq7PyA9pb5Q
|
||||
Ijritg5PdxfUMqd+H3CT++JvJmXxmIrFtKD9fZbmBgRg2wU7yvcp7MP+w36CYmqe
|
||||
MH1YF29VcBcF+GfI8k00y83qYHuoHpzPrNcL/Gu6MtcC+1Hy96bs+NYIEFH67dy8
|
||||
IJrN3Vvr4VXSF9qA+Vp5RatPv+hEKZssEFK2fNpGMFPGc1r+HBQ/HEAL4M2betEo
|
||||
Ne4DigJ3CkTIYAd+cZ2m4tdtzbqkeXZJ7SL+/d/5MIXKTnYITw+NrpiMZ6I72Zk=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,26 +1,21 @@
|
||||
Bag Attributes
|
||||
friendlyName: alias
|
||||
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
|
||||
subject=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
|
||||
issuer=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
Binary file not shown.
@@ -1,34 +1,34 @@
|
||||
Bag Attributes
|
||||
friendlyName: alias
|
||||
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
|
||||
localKeyID: C0 76 69 F4 6E D7 E6 03 D1 EB AD F1 A4 66 C4 14 3A 9B CB D4
|
||||
Key Attributes: <No Attributes>
|
||||
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||
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
|
||||
-----END ENCRYPTED PRIVATE KEY-----
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
Bag Attributes
|
||||
friendlyName: alias
|
||||
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
|
||||
localKeyID: C0 76 69 F4 6E D7 E6 03 D1 EB AD F1 A4 66 C4 14 3A 9B CB D4
|
||||
Key Attributes: <No Attributes>
|
||||
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||
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
|
||||
-----END ENCRYPTED PRIVATE KEY-----
|
||||
|
||||
+139
-67
@@ -24,22 +24,6 @@ import tools.jackson.databind.node.ObjectNode;
|
||||
/**
|
||||
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
|
||||
* A").
|
||||
*
|
||||
* <p>Calls:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #register} — relays the admin's short-lived Supabase JWT to {@code POST
|
||||
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
|
||||
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
|
||||
* GET /api/v1/instance/entitlement}; what the local gate consults.
|
||||
* <li>{@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports
|
||||
* cumulative units and returns the refreshed entitlement.
|
||||
* <li>{@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST
|
||||
* /api/v1/instance/revoke-self}).
|
||||
* </ul>
|
||||
*
|
||||
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see
|
||||
* {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -72,13 +56,7 @@ public class AccountLinkClient {
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
/** The device credential a successful {@link #register} returns. */
|
||||
public record RegisterResult(String deviceId, String deviceSecret, Long teamId) {}
|
||||
|
||||
/**
|
||||
* A non-2xx reply from the SaaS account-link API. Carries the upstream status so the caller can
|
||||
* map auth failures (401/403) through rather than masking everything as a 502.
|
||||
*/
|
||||
/** A non-2xx reply from the SaaS account-link API. */
|
||||
public static class UpstreamException extends IOException {
|
||||
private final int status;
|
||||
|
||||
@@ -92,11 +70,7 @@ public class AccountLinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a
|
||||
* transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on
|
||||
* this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch.
|
||||
*/
|
||||
/** Authoritative deny (401/403) — the device credential is revoked or invalid. */
|
||||
public static final class RevokedException extends RuntimeException {
|
||||
private final int status;
|
||||
|
||||
@@ -110,46 +84,142 @@ public class AccountLinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** What the SaaS side hands back when it records a connect handshake. */
|
||||
public record ConnectRequestResult(
|
||||
String requestId, int expiresInSeconds, String authorizeUrl) {}
|
||||
|
||||
public enum ConnectClaimOutcome {
|
||||
/** Approved and collected; the credential fields are populated. */
|
||||
GRANTED,
|
||||
/** A re-authentication was approved. */
|
||||
CONFIRMED,
|
||||
/** No human decision yet. */
|
||||
PENDING,
|
||||
/** Declined, expired or already used. */
|
||||
REJECTED,
|
||||
/** SaaS unreachable or erroring. */
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
public record ConnectClaimResult(
|
||||
ConnectClaimOutcome outcome, String deviceId, String deviceSecret, Long teamId) {
|
||||
static ConnectClaimResult of(ConnectClaimOutcome outcome) {
|
||||
return new ConnectClaimResult(outcome, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a connect handshake. */
|
||||
public ConnectRequestResult connectRequest(
|
||||
String name, String callbackUrl, String nonce, String claimSecret) throws IOException {
|
||||
return connectRequest(name, callbackUrl, nonce, claimSecret, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relays the admin Supabase JWT to the SaaS register endpoint and returns the minted
|
||||
* credential.
|
||||
*
|
||||
* @throws IOException on transport failure or a non-2xx response (caller surfaces to the
|
||||
* admin).
|
||||
* As {@link #connectRequest}, but presenting an existing device credential so the SaaS side
|
||||
* treats this as a re-authentication and pins the handshake to the team we already belong to.
|
||||
*/
|
||||
public RegisterResult register(String supabaseJwt, String instanceName) throws IOException {
|
||||
String body =
|
||||
instanceName == null || instanceName.isBlank()
|
||||
? "{}"
|
||||
: "{\"name\":" + mapper.writeValueAsString(instanceName) + "}";
|
||||
HttpRequest request =
|
||||
public ConnectRequestResult connectRequest(
|
||||
String name,
|
||||
String callbackUrl,
|
||||
String nonce,
|
||||
String claimSecret,
|
||||
DeviceCredential credential)
|
||||
throws IOException {
|
||||
ObjectNode root = mapper.createObjectNode();
|
||||
if (name != null && !name.isBlank()) {
|
||||
root.put("name", name);
|
||||
}
|
||||
root.put("callbackUrl", callbackUrl);
|
||||
root.put("nonce", nonce);
|
||||
root.put("claimSecret", claimSecret);
|
||||
|
||||
HttpRequest.Builder builder =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(uri("/api/v1/account-link/register"))
|
||||
.header("Authorization", "Bearer " + supabaseJwt)
|
||||
.uri(uri("/api/v1/account-link/connect/request"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.timeout(timeout())
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(root)));
|
||||
if (credential != null) {
|
||||
builder.header(HEADER_DEVICE_ID, credential.getDeviceId())
|
||||
.header(HEADER_DEVICE_SECRET, credential.getDeviceSecret());
|
||||
}
|
||||
|
||||
HttpResponse<String> response = send(request);
|
||||
HttpResponse<String> response = send(builder.build());
|
||||
if (response.statusCode() / 100 != 2) {
|
||||
throw new UpstreamException(response.statusCode(), response.body());
|
||||
}
|
||||
JsonNode root = mapper.readTree(response.body());
|
||||
String deviceId = text(root, "deviceId");
|
||||
String deviceSecret = text(root, "deviceSecret");
|
||||
if (deviceId == null || deviceSecret == null) {
|
||||
throw new IOException("SaaS register response missing deviceId/deviceSecret");
|
||||
JsonNode body = mapper.readTree(response.body());
|
||||
String requestId = text(body, "requestId");
|
||||
if (requestId == null) {
|
||||
throw new IOException("SaaS connect response missing requestId");
|
||||
}
|
||||
String authorizeUrl = text(body, "authorizeUrl");
|
||||
if (authorizeUrl == null || !isAbsoluteHttpUrl(authorizeUrl)) {
|
||||
throw new IOException("SaaS connect response carried no usable authorizeUrl");
|
||||
}
|
||||
return new ConnectRequestResult(requestId, body.path("expiresIn").asInt(0), authorizeUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the device credential for an approved handshake, proving possession of the claim
|
||||
* secret.
|
||||
*/
|
||||
public ConnectClaimResult connectClaim(String requestId, String claimSecret) {
|
||||
HttpResponse<String> response;
|
||||
try {
|
||||
ObjectNode root = mapper.createObjectNode();
|
||||
root.put("requestId", requestId);
|
||||
root.put("claimSecret", claimSecret);
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(uri("/api/v1/account-link/connect/claim"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.timeout(timeout())
|
||||
.POST(
|
||||
HttpRequest.BodyPublishers.ofString(
|
||||
mapper.writeValueAsString(root)))
|
||||
.build();
|
||||
response = send(request);
|
||||
} catch (Exception e) {
|
||||
log.debug("Connect claim failed (transport): {}", e.getMessage());
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE);
|
||||
}
|
||||
int status = response.statusCode();
|
||||
if (status == 202) {
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.PENDING);
|
||||
}
|
||||
if (status >= 500 && status <= 599) {
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE);
|
||||
}
|
||||
if (status < 200 || status > 299) {
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
|
||||
}
|
||||
try {
|
||||
JsonNode body = mapper.readTree(response.body());
|
||||
Long teamId = body.hasNonNull("teamId") ? body.get("teamId").asLong() : null;
|
||||
// A re-authentication says so explicitly and carries no credential, so an absent
|
||||
// credential is only an error when we were expecting one.
|
||||
if ("confirmed".equals(text(body, "status"))) {
|
||||
return new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, teamId);
|
||||
}
|
||||
String deviceId = text(body, "deviceId");
|
||||
String deviceSecret = text(body, "deviceSecret");
|
||||
if (deviceId == null || deviceSecret == null) {
|
||||
log.warn("Connect claim succeeded but the reply carried no credential");
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
|
||||
}
|
||||
return new ConnectClaimResult(
|
||||
ConnectClaimOutcome.GRANTED, deviceId, deviceSecret, teamId);
|
||||
} catch (RuntimeException e) {
|
||||
log.debug("Connect claim parse failed: {}", e.getMessage());
|
||||
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
|
||||
}
|
||||
Long teamId = root.hasNonNull("teamId") ? root.get("teamId").asLong() : null;
|
||||
return new RegisterResult(deviceId, deviceSecret, teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes this instance's own credential on the SaaS side, authenticated by that credential.
|
||||
* Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local
|
||||
* unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS.
|
||||
*/
|
||||
public boolean revokeSelf(String deviceId, String deviceSecret) {
|
||||
try {
|
||||
@@ -174,17 +244,7 @@ public class AccountLinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current entitlement using the stored device credential. Three outcomes:
|
||||
*
|
||||
* <ul>
|
||||
* <li>2xx → the parsed snapshot.
|
||||
* <li>401/403 → {@link RevokedException} (authoritative deny — revoked/invalid credential);
|
||||
* the caller must BLOCK, not fail open.
|
||||
* <li>transport failure, other non-2xx (e.g. 5xx), or a malformed body → {@code null}
|
||||
* ("unknown" — the caller fails open).
|
||||
* </ul>
|
||||
*/
|
||||
/** Fetches the current entitlement using the stored device credential. */
|
||||
public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) {
|
||||
HttpResponse<String> response;
|
||||
try {
|
||||
@@ -224,9 +284,6 @@ public class AccountLinkClient {
|
||||
/**
|
||||
* Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and
|
||||
* returns the fresh entitlement in the same reply — one round-trip both reports and refreshes.
|
||||
* SaaS bills the delta against its last-seen cumulative, so resending the same totals is
|
||||
* idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must
|
||||
* not advance its last-synced markers so the usage retries next sync.
|
||||
*/
|
||||
public InstanceEntitlement reportUsage(
|
||||
String deviceId,
|
||||
@@ -360,4 +417,19 @@ public class AccountLinkClient {
|
||||
private static String text(JsonNode node, String field) {
|
||||
return node.hasNonNull(field) ? node.get(field).asText() : null;
|
||||
}
|
||||
|
||||
/** Absolute http(s) with a host. */
|
||||
static boolean isAbsoluteHttpUrl(String candidate) {
|
||||
try {
|
||||
URI uri = URI.create(candidate.strip());
|
||||
String scheme = uri.getScheme();
|
||||
return uri.isAbsolute()
|
||||
&& scheme != null
|
||||
&& ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))
|
||||
&& uri.getHost() != null
|
||||
&& !uri.getHost().isBlank();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+88
-44
@@ -16,21 +16,11 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Same-origin account-link surface on the self-hosted instance (combined-billing "Mode A").
|
||||
*
|
||||
* <p>The portal (served from this same origin, admin authenticated by the existing self-hosted
|
||||
* security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
|
||||
* backend, which mints + returns a device credential we store locally. {@code GET /status} backs
|
||||
* the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds
|
||||
* to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now"
|
||||
* / test aid).
|
||||
*
|
||||
* <p>Admin-only, {@code @Profile("!saas")}, gated behind {@code
|
||||
* stirling.billing.account-link.enabled} — off → bean absent → 404.
|
||||
*/
|
||||
/** Same-origin account-link surface on the self-hosted instance (combined billing). */
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@@ -41,51 +31,110 @@ import lombok.extern.slf4j.Slf4j;
|
||||
public class AccountLinkController {
|
||||
|
||||
private final AccountLinkService service;
|
||||
private final ConnectService connectService;
|
||||
private final LocalUsageService localUsageService;
|
||||
// Present only when metering is on (its own flag); absent → /sync-now reports 409.
|
||||
private final ObjectProvider<UsageSyncService> syncServiceProvider;
|
||||
|
||||
public AccountLinkController(
|
||||
AccountLinkService service,
|
||||
ConnectService connectService,
|
||||
LocalUsageService localUsageService,
|
||||
ObjectProvider<UsageSyncService> syncServiceProvider) {
|
||||
this.service = service;
|
||||
this.connectService = connectService;
|
||||
this.localUsageService = localUsageService;
|
||||
this.syncServiceProvider = syncServiceProvider;
|
||||
}
|
||||
|
||||
/** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
|
||||
public record LinkRequest(String supabaseJwt, String name) {}
|
||||
/** {@code callbackUrl} is the portal telling us where its own callback route lives. */
|
||||
public record ConnectStartRequest(String name, String callbackUrl) {}
|
||||
|
||||
@PostMapping("/link")
|
||||
public ResponseEntity<?> link(@RequestBody LinkRequest req) {
|
||||
if (req == null || req.supabaseJwt() == null || req.supabaseJwt().isBlank()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(java.util.Map.of("error", "supabaseJwt is required"));
|
||||
}
|
||||
/** {@code nonce} comes from the callback fragment the approval page redirected to. */
|
||||
public record ConnectCompleteRequest(String nonce) {}
|
||||
|
||||
/**
|
||||
* Opens a browser-mediated link handshake and returns the approval URL to send the admin to.
|
||||
*/
|
||||
@PostMapping("/connect/start")
|
||||
public ResponseEntity<?> connectStart(
|
||||
@RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) {
|
||||
try {
|
||||
return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name()));
|
||||
return ResponseEntity.ok(
|
||||
connectService.start(req != null ? req.name() : null, callbackHint(req, http)));
|
||||
} catch (AccountLinkClient.UpstreamException e) {
|
||||
// Auth failures are the admin's token, not a gateway fault: surface 401/403 as-is so
|
||||
// the portal can prompt a re-sign-in. Anything else upstream → 502. Don't echo the
|
||||
// raw upstream body back to the browser.
|
||||
HttpStatus status =
|
||||
e.status() == HttpStatus.UNAUTHORIZED.value()
|
||||
|| e.status() == HttpStatus.FORBIDDEN.value()
|
||||
? HttpStatus.valueOf(e.status())
|
||||
: HttpStatus.BAD_GATEWAY;
|
||||
log.warn("Account-link register rejected upstream: HTTP {}", e.status());
|
||||
return ResponseEntity.status(status).body(java.util.Map.of("error", "LINK_FAILED"));
|
||||
} catch (IOException e) {
|
||||
// Don't echo e.getMessage() to the browser: a DNS/connection/TLS failure can carry the
|
||||
// configured SaaS host/IP. Log it server-side; return the same opaque body the
|
||||
// UpstreamException branch does.
|
||||
log.warn("Account-link failed (transport): {}", e.getMessage());
|
||||
log.warn("Account-link connect rejected upstream: HTTP {}", e.status());
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(java.util.Map.of("error", "LINK_FAILED"));
|
||||
.body(java.util.Map.of("error", "CONNECT_FAILED"));
|
||||
} catch (IOException e) {
|
||||
// Same reasoning as /link: a transport message can carry the configured SaaS host.
|
||||
log.warn("Account-link connect failed (transport): {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(java.util.Map.of("error", "CONNECT_FAILED"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-establishes the admin's SaaS session for a server that is already linked. */
|
||||
@PostMapping("/connect/reauth")
|
||||
public ResponseEntity<?> connectReauth(
|
||||
@RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) {
|
||||
try {
|
||||
return ResponseEntity.ok(connectService.startReauth(callbackHint(req, http)));
|
||||
} catch (AccountLinkClient.UpstreamException e) {
|
||||
log.warn("Account-link reauth rejected upstream: HTTP {}", e.status());
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(java.util.Map.of("error", "CONNECT_FAILED"));
|
||||
} catch (IOException e) {
|
||||
log.warn("Account-link reauth failed: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(java.util.Map.of("error", "CONNECT_FAILED"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by the callback page with the nonce it found in the fragment. */
|
||||
@PostMapping("/connect/complete")
|
||||
public ResponseEntity<ConnectService.ConnectStatus> connectComplete(
|
||||
@RequestBody(required = false) ConnectCompleteRequest req) {
|
||||
return ResponseEntity.ok(connectService.complete(req != null ? req.nonce() : null));
|
||||
}
|
||||
|
||||
/** Everything we know about where the admin's browser is, for the callback. */
|
||||
private static ConnectService.CallbackHint callbackHint(
|
||||
ConnectStartRequest req, HttpServletRequest http) {
|
||||
return new ConnectService.CallbackHint(
|
||||
req != null ? req.callbackUrl() : null, http.getHeader("Origin"), baseUrlOf(http));
|
||||
}
|
||||
|
||||
/**
|
||||
* This instance's base URL as the browser reached it, including any context path so a subpath
|
||||
* deployment builds a callback that actually resolves.
|
||||
*/
|
||||
private static String baseUrlOf(HttpServletRequest request) {
|
||||
String forwardedProto = firstHop(request.getHeader("X-Forwarded-Proto"));
|
||||
String forwardedHost = firstHop(request.getHeader("X-Forwarded-Host"));
|
||||
String scheme = forwardedProto != null ? forwardedProto : request.getScheme();
|
||||
String hostPort;
|
||||
if (forwardedHost != null) {
|
||||
hostPort = forwardedHost;
|
||||
} else {
|
||||
int port = request.getServerPort();
|
||||
boolean defaultPort =
|
||||
("http".equals(scheme) && port == 80)
|
||||
|| ("https".equals(scheme) && port == 443);
|
||||
hostPort = defaultPort ? request.getServerName() : request.getServerName() + ":" + port;
|
||||
}
|
||||
String context = request.getContextPath() == null ? "" : request.getContextPath();
|
||||
return scheme + "://" + hostPort + context;
|
||||
}
|
||||
|
||||
private static String firstHop(String headerValue) {
|
||||
if (headerValue == null || headerValue.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String first = headerValue.split(",")[0].strip();
|
||||
return first.isEmpty() ? null : first;
|
||||
}
|
||||
|
||||
@GetMapping("/status")
|
||||
public ResponseEntity<AccountLinkService.LinkStatus> status() {
|
||||
return ResponseEntity.ok(service.status());
|
||||
@@ -106,12 +155,7 @@ public class AccountLinkController {
|
||||
return ResponseEntity.ok(localUsageService.currentPeriodUnsynced());
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin
|
||||
* "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent:
|
||||
* re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run;
|
||||
* {@code 409} when metering is off (the sync bean is absent).
|
||||
*/
|
||||
/** Forces an immediate usage sync to SaaS — the same work the daily scheduler does. */
|
||||
@PostMapping("/sync-now")
|
||||
public ResponseEntity<Void> syncNow() {
|
||||
UsageSyncService sync = syncServiceProvider.getIfAvailable();
|
||||
|
||||
+8
-27
@@ -8,29 +8,17 @@ import org.springframework.stereotype.Component;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Self-hosted side of combined-billing "Mode A" (connected self-hosted).
|
||||
*
|
||||
* <p>Binds the {@code stirling.billing.account-link.*} keys. {@link #enabled} mirrors the same flag
|
||||
* the gated beans test with {@code @ConditionalOnProperty}; it is kept here only so non-conditional
|
||||
* code (e.g. the gate's flag-off short-circuit, exposed status) can read it. The whole feature is
|
||||
* <b>off by default</b> and <b>dark</b> — when off nothing gates and the link endpoints 404.
|
||||
*/
|
||||
/** Self-hosted side of combined billing: this instance bills through a linked SaaS team. */
|
||||
@Getter
|
||||
@Setter
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "stirling.billing.account-link")
|
||||
public class AccountLinkProperties {
|
||||
|
||||
/** Master switch. When {@code false} (default) the feature is fully inert. */
|
||||
/** Master switch. */
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* Base URL of the SaaS backend this instance links to (register + entitlement live there).
|
||||
*
|
||||
* <p>STUB: defaults to the public cloud host; an operator overrides it for staging. There is no
|
||||
* existing SaaS-base-url property in the self-hosted profile, so this is introduced here.
|
||||
*/
|
||||
/** Base URL of the SaaS backend this instance links to (register + entitlement live there). */
|
||||
private String saasBaseUrl = "https://stirling.com/app";
|
||||
|
||||
/** Cached entitlement is reused for this long before a refresh is attempted. */
|
||||
@@ -39,20 +27,18 @@ public class AccountLinkProperties {
|
||||
/** Connect/read timeout for the outbound SaaS calls. */
|
||||
private int requestTimeoutSeconds = 10;
|
||||
|
||||
/** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */
|
||||
/** Phase 2 usage metering + daily sync. */
|
||||
private final Metering metering = new Metering();
|
||||
|
||||
/**
|
||||
* Dedicated billing switch, <b>separate</b> from {@link #enabled} so the link plumbing can be
|
||||
* enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap
|
||||
* enforcement. Both default off; metering requires the master flag too. This is the production
|
||||
* safety key — flipping it on is what actually bills linked instances.
|
||||
* Separate from {@link #enabled} so linking can be exercised without billing anything. Both
|
||||
* default off, and metering needs the master flag as well.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Metering {
|
||||
|
||||
/** Turns on usage metering, the daily sync, and cap enforcement. Default off. */
|
||||
/** Turns on usage metering, the daily sync, and cap enforcement. */
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
@@ -65,12 +51,7 @@ public class AccountLinkProperties {
|
||||
*/
|
||||
private int graceDays = 3;
|
||||
|
||||
/**
|
||||
* Dedup window for identical input sets. A re-run of the same inputs within this window is
|
||||
* treated as workflow chaining and not re-charged; the same inputs run again after it are
|
||||
* billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op
|
||||
* costs the same on the instance and in the cloud.
|
||||
*/
|
||||
/** Dedup window for identical input sets. */
|
||||
private Duration workflowWindow = Duration.ofMinutes(5);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-24
@@ -1,6 +1,5 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
@@ -9,13 +8,7 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Linking orchestrator (self-hosted side of combined-billing "Mode A").
|
||||
*
|
||||
* <p>{@link #link} is the same-origin action the portal triggers: it relays the admin's Supabase
|
||||
* JWT to the SaaS register endpoint, then persists the returned device credential secure-at-rest.
|
||||
* The credential — not the JWT — authenticates all later unattended entitlement calls.
|
||||
*/
|
||||
/** Linking orchestrator (self-hosted side of combined billing). */
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@@ -38,24 +31,9 @@ public class AccountLinkService {
|
||||
/** Status of this instance's link, for the portal's "Account link" card. */
|
||||
public record LinkStatus(boolean linked, String deviceId, Long teamId, String linkedAt) {}
|
||||
|
||||
/**
|
||||
* Registers this instance with the SaaS team behind {@code supabaseJwt} and stores the
|
||||
* credential.
|
||||
*
|
||||
* @throws IOException if the SaaS register call fails (surfaced to the admin as a link error).
|
||||
*/
|
||||
public LinkStatus link(String supabaseJwt, String instanceName) throws IOException {
|
||||
AccountLinkClient.RegisterResult result = client.register(supabaseJwt, instanceName);
|
||||
credentialStore.save(result.deviceId(), result.deviceSecret(), result.teamId());
|
||||
entitlementCache.invalidate();
|
||||
log.info("Account-link: instance linked to team {}", result.teamId());
|
||||
return status();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlinks this instance — best-effort tells SaaS to revoke first (so the row gets {@code
|
||||
* revoked_at} set), then clears locally regardless. If SaaS is unreachable the local clear
|
||||
* still proceeds (admin's intent must win); the orphan row can be revoked from the portal.
|
||||
* revoked_at} set), then clears locally regardless.
|
||||
*/
|
||||
public void unlink() {
|
||||
credentialStore
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A").
|
||||
* Singleton row holding this instance's daily-sync bookkeeping (combined billing).
|
||||
*
|
||||
* <p>{@link #lastSyncSeq} is reserved (incremented + persisted) <em>before</em> each report so it
|
||||
* is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it,
|
||||
|
||||
+1
-1
@@ -2,5 +2,5 @@ package stirling.software.proprietary.accountlink;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */
|
||||
/** Persistence for the singleton {@link AccountLinkSyncState} (combined billing). */
|
||||
public interface AccountLinkSyncStateRepository extends JpaRepository<AccountLinkSyncState, Long> {}
|
||||
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/** Browser-mediated account linking, instance side. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class ConnectService {
|
||||
|
||||
/** Frontend route that consumes the callback fragment. */
|
||||
static final String CALLBACK_PATH = "/account-link/callback";
|
||||
|
||||
private static final int SECRET_BYTES = 32;
|
||||
|
||||
private final AccountLinkClient client;
|
||||
private final ConnectStateRepository stateRepo;
|
||||
private final DeviceCredentialStore credentialStore;
|
||||
private final EntitlementCache entitlementCache;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
public ConnectService(
|
||||
AccountLinkClient client,
|
||||
ConnectStateRepository stateRepo,
|
||||
DeviceCredentialStore credentialStore,
|
||||
EntitlementCache entitlementCache,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.client = client;
|
||||
this.stateRepo = stateRepo;
|
||||
this.credentialStore = credentialStore;
|
||||
this.entitlementCache = entitlementCache;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
public enum Phase {
|
||||
/** Nothing in flight and not linked. */
|
||||
NONE,
|
||||
/** A handshake is open, waiting for a leader to approve it on the SaaS site. */
|
||||
PENDING,
|
||||
/** Linked. */
|
||||
LINKED,
|
||||
/** The handshake outlived its window; start a new one. */
|
||||
EXPIRED,
|
||||
/** Declined or already used; start a new one. */
|
||||
REJECTED,
|
||||
/** SaaS could not be reached; the handshake is still valid and can be retried. */
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
/** What the portal renders. */
|
||||
public record ConnectStatus(
|
||||
Phase phase, String authorizeUrl, Long secondsRemaining, Long teamId) {
|
||||
static ConnectStatus of(Phase phase) {
|
||||
return new ConnectStatus(phase, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything we know about where the admin's browser actually is, in decreasing authority. */
|
||||
public record CallbackHint(
|
||||
String requestedCallbackUrl, String browserOrigin, String derivedBaseUrl) {}
|
||||
|
||||
/** Opens a handshake and returns where to send the admin. */
|
||||
@Transactional
|
||||
public ConnectStatus start(String name, CallbackHint hint) throws IOException {
|
||||
if (credentialStore.isLinked()) {
|
||||
return status();
|
||||
}
|
||||
return open(name, hint, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a handshake that only re-establishes the admin's browser session, for an instance that
|
||||
* is already linked.
|
||||
*/
|
||||
@Transactional
|
||||
public ConnectStatus startReauth(CallbackHint hint) throws IOException {
|
||||
DeviceCredential credential =
|
||||
credentialStore
|
||||
.get()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IOException(
|
||||
"This server is not linked, so there is no session"
|
||||
+ " to re-establish"));
|
||||
return open(credential.getDeviceId(), hint, credential);
|
||||
}
|
||||
|
||||
private ConnectStatus open(String name, CallbackHint hint, DeviceCredential credential)
|
||||
throws IOException {
|
||||
String callbackUrl = resolveCallbackUrl(hint);
|
||||
if (callbackUrl == null) {
|
||||
throw new IOException(
|
||||
"Cannot determine where to send the admin back to; set system.frontendUrl");
|
||||
}
|
||||
String nonce = randomSecret();
|
||||
String claimSecret = randomSecret();
|
||||
|
||||
AccountLinkClient.ConnectRequestResult created =
|
||||
client.connectRequest(name, callbackUrl, nonce, claimSecret, credential);
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
ConnectState state = new ConnectState();
|
||||
state.setId(ConnectState.SINGLETON_ID);
|
||||
state.setRequestId(created.requestId());
|
||||
state.setNonce(nonce);
|
||||
state.setClaimSecret(claimSecret);
|
||||
state.setCallbackUrl(callbackUrl);
|
||||
state.setAuthorizeUrl(created.authorizeUrl());
|
||||
state.setCreatedAt(now);
|
||||
state.setExpiresAt(
|
||||
now.plusSeconds(created.expiresInSeconds() > 0 ? created.expiresInSeconds() : 900));
|
||||
stateRepo.save(state);
|
||||
|
||||
log.info("Account-link connect: handshake {} opened", created.requestId());
|
||||
return pendingStatus(state, now);
|
||||
}
|
||||
|
||||
/** Finishes a handshake from the callback the approval page redirected to. */
|
||||
@Transactional
|
||||
public ConnectStatus complete(String nonce) {
|
||||
Optional<ConnectState> found = stateRepo.findById(ConnectState.SINGLETON_ID);
|
||||
if (found.isEmpty()) {
|
||||
// Already finished (a double-submitted callback) or never started.
|
||||
return status();
|
||||
}
|
||||
ConnectState state = found.get();
|
||||
if (state.isExpired(LocalDateTime.now())) {
|
||||
stateRepo.delete(state);
|
||||
return ConnectStatus.of(Phase.EXPIRED);
|
||||
}
|
||||
if (nonce == null || !nonceMatches(nonce, state.getNonce())) {
|
||||
log.warn(
|
||||
"Account-link connect: callback for handshake {} had a bad nonce",
|
||||
state.getRequestId());
|
||||
return ConnectStatus.of(Phase.REJECTED);
|
||||
}
|
||||
|
||||
AccountLinkClient.ConnectClaimResult claim =
|
||||
client.connectClaim(state.getRequestId(), state.getClaimSecret());
|
||||
return switch (claim.outcome()) {
|
||||
case GRANTED -> {
|
||||
credentialStore.save(claim.deviceId(), claim.deviceSecret(), claim.teamId());
|
||||
entitlementCache.invalidate();
|
||||
stateRepo.delete(state);
|
||||
log.info("Account-link connect: linked to team {}", claim.teamId());
|
||||
yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId());
|
||||
}
|
||||
case CONFIRMED -> {
|
||||
stateRepo.delete(state);
|
||||
log.info(
|
||||
"Account-link connect: session re-established for team {}", claim.teamId());
|
||||
yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId());
|
||||
}
|
||||
case PENDING ->
|
||||
// The admin reached the callback before the approval committed. The row stays,
|
||||
// so a retry finishes it.
|
||||
ConnectStatus.of(Phase.PENDING);
|
||||
case REJECTED -> {
|
||||
stateRepo.delete(state);
|
||||
yield ConnectStatus.of(Phase.REJECTED);
|
||||
}
|
||||
case UNAVAILABLE -> ConnectStatus.of(Phase.UNAVAILABLE);
|
||||
};
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ConnectStatus status() {
|
||||
Optional<DeviceCredential> credential = credentialStore.get();
|
||||
if (credential.isPresent()) {
|
||||
return new ConnectStatus(Phase.LINKED, null, null, credential.get().getTeamId());
|
||||
}
|
||||
Optional<ConnectState> state = stateRepo.findById(ConnectState.SINGLETON_ID);
|
||||
if (state.isEmpty()) {
|
||||
return ConnectStatus.of(Phase.NONE);
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (state.get().isExpired(now)) {
|
||||
return ConnectStatus.of(Phase.EXPIRED);
|
||||
}
|
||||
return pendingStatus(state.get(), now);
|
||||
}
|
||||
|
||||
private static ConnectStatus pendingStatus(ConnectState state, LocalDateTime now) {
|
||||
long remaining = Duration.between(now, state.getExpiresAt()).toSeconds();
|
||||
return new ConnectStatus(
|
||||
Phase.PENDING, state.getAuthorizeUrl(), Math.max(remaining, 0), null);
|
||||
}
|
||||
|
||||
/** Decides the callback, preferring knowledge over inference. */
|
||||
String resolveCallbackUrl(CallbackHint hint) {
|
||||
String configured = applicationProperties.getSystem().getFrontendUrl();
|
||||
if (configured != null && !configured.isBlank()) {
|
||||
return trimTrailingSlash(configured.strip()) + CALLBACK_PATH;
|
||||
}
|
||||
String browserOrigin = originOf(hint.browserOrigin());
|
||||
if (browserOrigin != null) {
|
||||
String requested = hint.requestedCallbackUrl();
|
||||
if (requested != null && browserOrigin.equals(originOf(requested))) {
|
||||
return requested.strip();
|
||||
}
|
||||
return browserOrigin + CALLBACK_PATH;
|
||||
}
|
||||
return hint.derivedBaseUrl() == null || hint.derivedBaseUrl().isBlank()
|
||||
? null
|
||||
: trimTrailingSlash(hint.derivedBaseUrl().strip()) + CALLBACK_PATH;
|
||||
}
|
||||
|
||||
/** Scheme, host and port of an absolute http(s) URL; null if it is not one. */
|
||||
private static String originOf(String candidate) {
|
||||
if (candidate == null || candidate.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(candidate.strip());
|
||||
} catch (URISyntaxException e) {
|
||||
return null;
|
||||
}
|
||||
if (uri.getScheme() == null || uri.getHost() == null) {
|
||||
return null;
|
||||
}
|
||||
String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
|
||||
if (!"http".equals(scheme) && !"https".equals(scheme)) {
|
||||
return null;
|
||||
}
|
||||
int port = uri.getPort();
|
||||
boolean defaultPort =
|
||||
port == -1
|
||||
|| ("http".equals(scheme) && port == 80)
|
||||
|| ("https".equals(scheme) && port == 443);
|
||||
return defaultPort
|
||||
? scheme + "://" + uri.getHost()
|
||||
: scheme + "://" + uri.getHost() + ":" + port;
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String value) {
|
||||
return value.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
private String randomSecret() {
|
||||
byte[] buf = new byte[SECRET_BYTES];
|
||||
random.nextBytes(buf);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
|
||||
}
|
||||
|
||||
/** Constant-time so a caller cannot probe the nonce a character at a time. */
|
||||
private static boolean nonceMatches(String candidate, String expected) {
|
||||
if (expected == null) {
|
||||
return false;
|
||||
}
|
||||
return MessageDigest.isEqual(
|
||||
candidate.getBytes(StandardCharsets.UTF_8),
|
||||
expected.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/** The one in-flight "connect this server" handshake, instance side. */
|
||||
@Entity
|
||||
@Table(name = "account_link_connect_state")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ConnectState implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final Long SINGLETON_ID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private Long id = SINGLETON_ID;
|
||||
|
||||
/** Opaque handle the SaaS side gave us; identifies the handshake on both sides. */
|
||||
@Column(name = "request_id", nullable = false, length = 64)
|
||||
private String requestId;
|
||||
|
||||
/** Correlator we minted. */
|
||||
@Column(name = "nonce", nullable = false, length = 128)
|
||||
private String nonce;
|
||||
|
||||
/** Secret we minted and sent to SaaS server to server. */
|
||||
@Column(name = "claim_secret", nullable = false, length = 128)
|
||||
private String claimSecret;
|
||||
|
||||
/** Where we asked the approval page to send the admin back to. */
|
||||
@Column(name = "callback_url", nullable = false, length = 2048)
|
||||
private String callbackUrl;
|
||||
|
||||
/** The approval URL handed to the browser, so a reload can offer it again. */
|
||||
@Column(name = "authorize_url", nullable = false, length = 2048)
|
||||
private String authorizeUrl;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
public boolean isExpired(LocalDateTime now) {
|
||||
return expiresAt != null && expiresAt.isBefore(now);
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Data access for the singleton {@link ConnectState} row. */
|
||||
public interface ConnectStateRepository extends JpaRepository<ConnectState, Long> {}
|
||||
+2
-2
@@ -13,8 +13,8 @@ import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* The device credential this self-hosted instance received when it linked a SaaS account
|
||||
* (combined-billing "Mode A"). Singleton — one instance links to exactly one SaaS team.
|
||||
* The device credential this self-hosted instance received when it linked a SaaS account (combined
|
||||
* billing). Singleton — one instance links to exactly one SaaS team.
|
||||
*
|
||||
* <p>Unlike the SaaS side (which stores only a hash), the instance must keep the plaintext {@code
|
||||
* deviceSecret} so it can present it on every unattended entitlement call. It lives in the local
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Decides whether a request may proceed under combined-billing "Mode A" on a self-hosted instance.
|
||||
* Decides whether a request may proceed under combined billing on a self-hosted instance.
|
||||
*
|
||||
* <p>Rules (in order):
|
||||
*
|
||||
|
||||
+2
-2
@@ -39,8 +39,8 @@ import stirling.software.proprietary.policy.controller.PolicyRunRoutes;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
|
||||
/**
|
||||
* Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API /
|
||||
* AI / automation) work when the instance is unlinked or over its limit; manual tools pass through.
|
||||
* Request-time gate + meter for combined billing. {@code preHandle} blocks billable (API / AI /
|
||||
* automation) work when the instance is unlinked or over its limit; manual tools pass through.
|
||||
* {@code afterCompletion} meters a successful billable op into the per-period cumulative counter.
|
||||
*
|
||||
* <p>Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate"
|
||||
|
||||
+5
-5
@@ -16,11 +16,11 @@ import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* The last time the instance metered a given input set this period — the local equivalent of the
|
||||
* cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling <b>workflow
|
||||
* window</b>: an identical input set re-submitted within the window (see {@link
|
||||
* AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the
|
||||
* same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job
|
||||
* window so the same operation costs the same on the instance and in the cloud.
|
||||
* cloud's lineage join (combined billing). The meter dedups on a rolling <b>workflow window</b>: an
|
||||
* identical input set re-submitted within the window (see {@link AccountLinkProperties.Metering})
|
||||
* is treated as workflow chaining and not re-charged, while the same inputs run again after the
|
||||
* window are billed afresh — matching the cloud's 5-minute open-job window so the same operation
|
||||
* costs the same on the instance and in the cloud.
|
||||
*
|
||||
* <p>{@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud
|
||||
* artifact touches its job). One row per {@code (period, signature)}; the unique constraint also
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */
|
||||
/** Persistence for the per-period metered input-set signatures (combined billing). */
|
||||
public interface MeteredInputSignatureRepository
|
||||
extends JpaRepository<MeteredInputSignature, Long> {
|
||||
|
||||
|
||||
+4
-4
@@ -17,10 +17,10 @@ import lombok.NoArgsConstructor;
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A".
|
||||
* Each successful billable op increments its row; the daily sync reports the cumulative totals and
|
||||
* SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills
|
||||
* nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
|
||||
* Durable per-(billing period, category) cumulative usage counter for combined billing. Each
|
||||
* successful billable op increments its row; the daily sync reports the cumulative totals and SaaS
|
||||
* bills the delta since the last sync. The cumulative model is idempotent (a resend bills nothing)
|
||||
* and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
|
||||
* category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it.
|
||||
*/
|
||||
@Entity
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */
|
||||
/** Persistence for the per-period/per-category usage counters (combined billing). */
|
||||
public interface UsageCounterRepository extends JpaRepository<UsageCounter, Long> {
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -18,8 +18,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category
|
||||
* usage to SaaS, which bills the delta against its own last-seen totals.
|
||||
* Daily usage sender for combined billing. Reports each period's cumulative per-category usage to
|
||||
* SaaS, which bills the delta against its own last-seen totals.
|
||||
*
|
||||
* <p>Resilience: the sync seq is persisted before the report so it never regresses across
|
||||
* restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so
|
||||
|
||||
+3
-3
@@ -11,9 +11,9 @@ import java.util.HexFormat;
|
||||
|
||||
/**
|
||||
* SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's
|
||||
* meter (combined-billing "Mode A"), so both derive an <em>identical</em> signature for the same
|
||||
* bytes — the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent
|
||||
* of file size), hardware-accelerated by the JVM where available.
|
||||
* meter (combined billing), so both derive an <em>identical</em> signature for the same bytes — the
|
||||
* basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent of file
|
||||
* size), hardware-accelerated by the JVM where available.
|
||||
*
|
||||
* <p>Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core
|
||||
* build yet is reachable from {@code :saas} (which depends on {@code :proprietary}).
|
||||
|
||||
+22
-15
@@ -1,20 +1,17 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Why an action could not be dispatched. Carries a {@link Reason} rather than an HTTP status, so
|
||||
* the service stays web-agnostic and the controller owns the mapping.
|
||||
*/
|
||||
/** Carries a {@link Reason} rather than an HTTP status, so the service stays web-agnostic. */
|
||||
@Getter
|
||||
public class FailureActionException extends RuntimeException {
|
||||
|
||||
public enum Reason {
|
||||
/**
|
||||
* No such event, it belongs to another team, or the caller's team did not resolve. One
|
||||
* reason for all three, so the response does not vary with which it was. Unrelated to
|
||||
* {@link FailureKind#UNKNOWN}, which is an unclassified failure rather than a refused
|
||||
* action.
|
||||
* No such event, another team's, or an unresolved team: one reason, so the answer cannot
|
||||
* vary.
|
||||
*/
|
||||
EVENT_NOT_FOUND,
|
||||
|
||||
@@ -22,15 +19,13 @@ public class FailureActionException extends RuntimeException {
|
||||
ACTION_NOT_RECOGNISED,
|
||||
|
||||
/**
|
||||
* The action exists but this kind does not declare it, so an incoherent pairing (releasing
|
||||
* a document whose destination is what failed) cannot be dispatched even by hand.
|
||||
*
|
||||
* <p>Unreachable today: both kinds declare both actions, so no request can trip this guard
|
||||
* until a kind ships with a restricted action set. Declared now because the guard must
|
||||
* exist before that kind does, not after.
|
||||
* The action exists but this kind does not offer it, so it cannot be dispatched by hand.
|
||||
*/
|
||||
ACTION_NOT_DECLARED,
|
||||
|
||||
/** Offered, but the client is what runs it, so refused rather than half-performed. */
|
||||
ACTION_NOT_DISPATCHABLE,
|
||||
|
||||
/** The event is already closed, so no further transition is possible. */
|
||||
ALREADY_CLOSED
|
||||
}
|
||||
@@ -41,9 +36,21 @@ public class FailureActionException extends RuntimeException {
|
||||
this(reason, message, null);
|
||||
}
|
||||
|
||||
/** For a refusal that follows from a lower-level failure, so its stack is not dropped. */
|
||||
public FailureActionException(Reason reason, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lives with the reasons it maps, so every surface that dispatches an action answers alike. A
|
||||
* closed row is a conflict, not a bad request: it was well-formed and valid a moment earlier.
|
||||
*/
|
||||
public static HttpStatus statusOf(Reason reason) {
|
||||
return switch (reason) {
|
||||
case EVENT_NOT_FOUND -> HttpStatus.NOT_FOUND;
|
||||
case ACTION_NOT_RECOGNISED, ACTION_NOT_DECLARED, ACTION_NOT_DISPATCHABLE ->
|
||||
HttpStatus.BAD_REQUEST;
|
||||
case ALREADY_CLOSED -> HttpStatus.CONFLICT;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+46
-5
@@ -1,11 +1,52 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* The actions a {@link FailureKind} may declare. Both are incident dispositions: they change how
|
||||
* the event is shown and touch nothing else, which is what makes them valid for every kind
|
||||
* including {@link FailureKind#UNKNOWN}, and why there is no {@code APPROVE} yet.
|
||||
* The actions a {@link FailureKind} may declare. Client actions are declared here rather than
|
||||
* invented per client, so the server keeps deciding what a kind offers, in what order and labelled
|
||||
* how.
|
||||
*/
|
||||
@Getter
|
||||
public enum FailureActionId {
|
||||
ACKNOWLEDGE,
|
||||
DISMISS
|
||||
|
||||
/**
|
||||
* Kept in the vocabulary for as long as any persisted row is {@code ACKNOWLEDGED}: such rows
|
||||
* must stay readable and closable whether or not any kind currently offers this.
|
||||
*/
|
||||
ACKNOWLEDGE(Execution.SERVER, "Acknowledge"),
|
||||
|
||||
DISMISS(Execution.SERVER, "Dismiss"),
|
||||
|
||||
/** Open the document behind the incident, in whichever client can resolve its id. */
|
||||
VIEW_FILE(Execution.CLIENT, "View file"),
|
||||
|
||||
VIEW_IN_PROCESSOR(Execution.CLIENT, "View in processor");
|
||||
|
||||
/** Dispatch refuses a {@code CLIENT} id, so this is enforced rather than merely documented. */
|
||||
public enum Execution {
|
||||
|
||||
/** {@link FailureActionRegistry} requires a {@link FailureAction} bean for these. */
|
||||
SERVER,
|
||||
|
||||
/**
|
||||
* Declared and rendered, never dispatched: the server has neither the file nor the tool.
|
||||
*/
|
||||
CLIENT
|
||||
}
|
||||
|
||||
private final Execution execution;
|
||||
|
||||
/** English fallback, for a client with no translation for the label key. */
|
||||
private final String defaultLabel;
|
||||
|
||||
FailureActionId(Execution execution, String defaultLabel) {
|
||||
this.execution = execution;
|
||||
this.defaultLabel = defaultLabel;
|
||||
}
|
||||
|
||||
/** Also whether it can be dispatched. */
|
||||
public boolean runsOnServer() {
|
||||
return execution == Execution.SERVER;
|
||||
}
|
||||
}
|
||||
|
||||
+13
-4
@@ -16,6 +16,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
* Resolves a {@link FailureActionId} to the bean that implements it. The startup check is the
|
||||
* point: because kinds declare action ids as data, one could name an action nobody implements,
|
||||
* which would otherwise show up as a button that 400s rather than as a failed boot.
|
||||
*
|
||||
* <p>Only {@link FailureActionId.Execution#SERVER} ids belong here: a bean for a client action is
|
||||
* refused, because dispatch could never reach it.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -25,6 +28,14 @@ public class FailureActionRegistry {
|
||||
|
||||
public FailureActionRegistry(List<FailureAction> actions) {
|
||||
for (FailureAction action : actions) {
|
||||
if (!action.id().runsOnServer()) {
|
||||
throw new IllegalStateException(
|
||||
"Action "
|
||||
+ action.id()
|
||||
+ " is run by the client, so "
|
||||
+ action.getClass().getName()
|
||||
+ " could never be dispatched");
|
||||
}
|
||||
FailureAction clash = byId.put(action.id(), action);
|
||||
if (clash != null) {
|
||||
throw new IllegalStateException(
|
||||
@@ -38,10 +49,7 @@ public class FailureActionRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail fast if any kind declares an action with no handler, naming every gap rather than the
|
||||
* first, so one boot tells you everything that is missing.
|
||||
*/
|
||||
/** Names every gap rather than the first, so one boot tells you everything that is missing. */
|
||||
@PostConstruct
|
||||
void verifyEveryDeclaredActionHasAHandler() {
|
||||
List<String> gaps =
|
||||
@@ -49,6 +57,7 @@ public class FailureActionRegistry {
|
||||
.flatMap(
|
||||
kind ->
|
||||
kind.getActions().stream()
|
||||
.filter(FailureActionId::runsOnServer)
|
||||
.filter(action -> !byId.containsKey(action))
|
||||
.map(action -> kind.getId() + " -> " + action))
|
||||
.toList();
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
/**
|
||||
* Who an offered action is for, the read scope having already decided they may see the incident.
|
||||
* The distinction is possession, not seniority: a reviewer cannot reach a document only its owner
|
||||
* holds.
|
||||
*/
|
||||
public enum FailureAudience {
|
||||
OWNER,
|
||||
|
||||
/** Anyone who triages the team's incidents, whoever hit them. */
|
||||
TEAM_REVIEWER,
|
||||
|
||||
ANYONE_WHO_SEES
|
||||
}
|
||||
+44
-20
@@ -1,7 +1,11 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static stirling.software.proprietary.failure.FailureActionId.ACKNOWLEDGE;
|
||||
import static stirling.software.proprietary.failure.FailureActionId.DISMISS;
|
||||
import static stirling.software.proprietary.failure.FailureActionId.VIEW_FILE;
|
||||
import static stirling.software.proprietary.failure.FailureActionId.VIEW_IN_PROCESSOR;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.ANYONE_WHO_SEES;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.OWNER;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.TEAM_REVIEWER;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
@@ -20,13 +24,8 @@ import lombok.Getter;
|
||||
* The registry of failure kinds, described as data: a stable id, i18n keys and an English fallback
|
||||
* like {@code ExceptionUtils.ErrorCode}, plus the facets a review surface needs.
|
||||
*
|
||||
* <p>Actions are declared here but implemented in {@link FailureAction} beans resolved by id, so a
|
||||
* new kind ships as a registry entry plus copy. Two members today: {@link #UNKNOWN} gives every
|
||||
* failed run a record, and kinds get promoted out of it as production shows what occurs.
|
||||
*
|
||||
* <p>A kind offers an acknowledgement only where there is something to acknowledge <em>doing</em>.
|
||||
* With nothing to fix, "seen it" and "clear it" are the same decision, so the row offers only the
|
||||
* one that clears it.
|
||||
* <p>A new kind ships as a registry entry plus copy. Each offer also says who it is for, since one
|
||||
* incident is read both by whoever hit it and by whoever reviews after them.
|
||||
*/
|
||||
@Getter
|
||||
public enum FailureKind {
|
||||
@@ -37,8 +36,9 @@ public enum FailureKind {
|
||||
FailureScope.FILE,
|
||||
errorCodes("E004"),
|
||||
fallback("This document is password-protected, so the pipeline could not read it."),
|
||||
offer(ACKNOWLEDGE),
|
||||
offer(DISMISS, "dismissSkipFile")),
|
||||
offer(VIEW_FILE, OWNER),
|
||||
offer(VIEW_IN_PROCESSOR, TEAM_REVIEWER),
|
||||
offer(DISMISS, ANYONE_WHO_SEES)),
|
||||
|
||||
UNKNOWN(
|
||||
FailureStage.INTERNAL,
|
||||
@@ -47,7 +47,11 @@ public enum FailureKind {
|
||||
FailureScope.RUN,
|
||||
noErrorCodes(),
|
||||
fallback("This run failed for a reason Stirling does not yet recognise."),
|
||||
offer(DISMISS));
|
||||
// Same order as every other kind: declaration order is display order, so the document
|
||||
// leads wherever it is offered rather than moving between failures.
|
||||
offer(VIEW_FILE, OWNER),
|
||||
offer(VIEW_IN_PROCESSOR, TEAM_REVIEWER),
|
||||
offer(DISMISS, ANYONE_WHO_SEES));
|
||||
|
||||
private static final String KEY_PREFIX = "portal.failures.kind.";
|
||||
private static final String ACTION_KEY_PREFIX = "portal.failures.action.";
|
||||
@@ -95,22 +99,26 @@ public enum FailureKind {
|
||||
}
|
||||
|
||||
/**
|
||||
* One action this kind offers, with the key to label it by. One ordered list rather than ids
|
||||
* plus a parallel map of overrides, which could disagree with each other.
|
||||
* One ordered list rather than ids plus parallel maps of audiences and labels, which could
|
||||
* disagree with each other.
|
||||
*
|
||||
* @param labelKeySuffix key under {@code portal.failures.action.}, or null for the generic
|
||||
* label
|
||||
*/
|
||||
private record Offer(FailureActionId id, String labelKeySuffix) {}
|
||||
private record Offer(FailureActionId id, FailureAudience audience, String labelKeySuffix) {}
|
||||
|
||||
/** An action labelled by this kind's own wording, where the generic label reads badly. */
|
||||
private static Offer offer(FailureActionId id, String labelKeySuffix) {
|
||||
return new Offer(id, labelKeySuffix);
|
||||
/** Declaration order is display order. */
|
||||
private static Offer offer(FailureActionId id, FailureAudience audience) {
|
||||
return new Offer(id, audience, null);
|
||||
}
|
||||
|
||||
/** An action labelled by the shared wording for that action. */
|
||||
private static Offer offer(FailureActionId id) {
|
||||
return new Offer(id, null);
|
||||
/**
|
||||
* As {@link #offer(FailureActionId, FailureAudience)}, but labelled by this kind's own wording
|
||||
* where the shared one reads badly.
|
||||
*/
|
||||
private static Offer offer(
|
||||
FailureActionId id, FailureAudience audience, String labelKeySuffix) {
|
||||
return new Offer(id, audience, labelKeySuffix);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,6 +157,22 @@ public enum FailureKind {
|
||||
return offers.stream().map(Offer::id).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* What this kind offers, in declaration order, each with its label resolved. What a review
|
||||
* surface reads, so it never has to ask two separate questions about one offer.
|
||||
*/
|
||||
public List<OfferedAction> getOfferedActions() {
|
||||
return offers.stream()
|
||||
.map(
|
||||
offer ->
|
||||
new OfferedAction(
|
||||
offer.id(), labelKeyFor(offer.id()), offer.audience()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** One action as a kind declares it: what to call it and who it is for. */
|
||||
public record OfferedAction(FailureActionId id, String labelKey, FailureAudience audience) {}
|
||||
|
||||
/** Whether this kind offers {@code action}. The dispatch guard: see {@code FailureActionId}. */
|
||||
public boolean declares(FailureActionId action) {
|
||||
return offers.stream().anyMatch(offer -> offer.id() == action);
|
||||
|
||||
+11
-17
@@ -74,8 +74,10 @@ public class FileRunEventController {
|
||||
@Operation(
|
||||
summary = "Apply an action to a recorded failure",
|
||||
description =
|
||||
"Rejected with 400 if the failure's kind does not declare the action, so an"
|
||||
+ " action that makes no sense for a given failure cannot be applied.")
|
||||
"Rejected with 400 if the failure's kind does not declare the action, or if the"
|
||||
+ " action is one the client runs rather than the server, so neither an"
|
||||
+ " action that makes no sense for a given failure nor one the server"
|
||||
+ " cannot perform can be applied.")
|
||||
public FileRunEventView act(
|
||||
@PathVariable String eventId,
|
||||
@PathVariable String actionId,
|
||||
@@ -87,7 +89,8 @@ public class FileRunEventController {
|
||||
FileRunEvent updated = service.dispatch(eventId, actionId, inputs);
|
||||
return FileRunEventView.of(updated, service.availableActions(updated));
|
||||
} catch (FailureActionException e) {
|
||||
throw new ResponseStatusException(statusFor(e.getReason()), e.getMessage(), e);
|
||||
throw new ResponseStatusException(
|
||||
FailureActionException.statusOf(e.getReason()), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,18 +150,6 @@ public class FileRunEventController {
|
||||
return Arrays.stream(FailureKind.values()).map(FailureKindView::of).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* A closed row is a conflict rather than a bad request: the request was well-formed and would
|
||||
* have been valid a moment earlier.
|
||||
*/
|
||||
private static HttpStatus statusFor(FailureActionException.Reason reason) {
|
||||
return switch (reason) {
|
||||
case EVENT_NOT_FOUND -> HttpStatus.NOT_FOUND;
|
||||
case ACTION_NOT_RECOGNISED, ACTION_NOT_DECLARED -> HttpStatus.BAD_REQUEST;
|
||||
case ALREADY_CLOSED -> HttpStatus.CONFLICT;
|
||||
};
|
||||
}
|
||||
|
||||
/** Wrapped rather than a bare array so pagination can be added without breaking clients. */
|
||||
public record FileRunEventsResponse(List<FileRunEventView> events) {}
|
||||
|
||||
@@ -178,10 +169,13 @@ public class FileRunEventController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Inputs an action declared it needs. Empty for both actions that exist today. */
|
||||
/**
|
||||
* Inputs an action declared it needs. Empty for every action the server runs today: the one
|
||||
* that needs a password is run by the client, which never sends it here.
|
||||
*/
|
||||
public record ActionRequest(Map<String, String> inputs) {
|
||||
|
||||
Map<String, String> safeInputs() {
|
||||
public Map<String, String> safeInputs() {
|
||||
return inputs == null ? Map.of() : inputs;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-8
@@ -98,20 +98,18 @@ public interface FileRunEventRepository extends JpaRepository<FileRunEventEntity
|
||||
* Close the incidents about documents their owner deleted from the editor: the queue is what
|
||||
* needs attention, and a document that no longer exists needs none.
|
||||
*
|
||||
* <p>Restricted to that owner's own editor rows. File ids are minted by the client, so scoping
|
||||
* on team alone would let one caller close a colleague's incidents by naming ids. Processor
|
||||
* rows are excluded outright: nothing was deleted from an editor there.
|
||||
* <p>Scoped by the absence of a source rather than by origin: a source-fed run's {@code fileId}
|
||||
* is a hash no client can name. Narrowed to the owner's own rows, since clients mint the ids.
|
||||
*/
|
||||
@Modifying(clearAutomatically = true)
|
||||
@Transactional
|
||||
@Query(
|
||||
"update FileRunEventEntity e set e.status ="
|
||||
+ " stirling.software.proprietary.failure.FileRunEventStatus.FILE_REMOVED,"
|
||||
+ " e.statusActor = :actor, e.statusAt = :now where e.origin ="
|
||||
+ " stirling.software.proprietary.failure.FailureOrigin.TOOL and ((:teamId is"
|
||||
+ " null and e.teamId is null) or e.teamId = :teamId) and ((:actor is null and"
|
||||
+ " e.actor is null) or e.actor = :actor) and e.fileId in :fileIds and e.status in"
|
||||
+ " :allowedFrom")
|
||||
+ " e.statusActor = :actor, e.statusAt = :now where e.sourceId is null and"
|
||||
+ " ((:teamId is null and e.teamId is null) or e.teamId = :teamId) and"
|
||||
+ " ((:actor is null and e.actor is null) or e.actor = :actor) and e.fileId in"
|
||||
+ " :fileIds and e.status in :allowedFrom")
|
||||
int markFilesRemoved(
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("actor") String actor,
|
||||
|
||||
+99
-35
@@ -21,12 +21,22 @@ import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
* team always comes from the authenticated principal, and scoping applies only when login is
|
||||
* enabled so single-user deployments keep working. When the team cannot be resolved the caller
|
||||
* reads nothing; see {@link #readScope()}.
|
||||
*
|
||||
* <p>The read scope decides who sees an incident; {@link #availableActions} decides who may act.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FileRunEventService {
|
||||
|
||||
/** Why an offered action came back disabled. Copy lives under {@code portal.failures}. */
|
||||
private static final String CLOSED_REASON_KEY = "portal.failures.disabled.closed";
|
||||
|
||||
private static final String UNATTENDED_REASON_KEY = "portal.failures.disabled.unattended";
|
||||
|
||||
/** The row never named a document, so unlike the unattended case no client can find one. */
|
||||
private static final String DOCUMENTLESS_REASON_KEY = "portal.failures.disabled.noDocument";
|
||||
|
||||
private final FileRunEventStore store;
|
||||
private final FailureActionRegistry actionRegistry;
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
@@ -116,36 +126,17 @@ public class FileRunEventService {
|
||||
* Dispatch an action against one event.
|
||||
*
|
||||
* @throws FailureActionException if the event is not the caller's, the action is unknown, the
|
||||
* event's kind does not declare the action, or the event is already closed
|
||||
* event's kind does not declare the action, the client is what runs the action, or the
|
||||
* event is already closed
|
||||
*/
|
||||
public FileRunEvent dispatch(String eventId, String actionId, Map<String, String> inputs) {
|
||||
// Whoever can see it can close it: a leader for the whole team, everyone else for the
|
||||
// failures they caused. Someone who fixes their own problem should not have to ask a leader
|
||||
// to clear the row.
|
||||
//
|
||||
// Closing the row is all this covers. Acting on the document behind it, such as supplying a
|
||||
// password for a retry, would need its own permission, and no such action exists yet.
|
||||
ReadScope scope = readScope();
|
||||
if (!scope.permitted()) {
|
||||
// Reported as "no such event", the same as an id from another team, so the response
|
||||
// does
|
||||
// not depend on whether the id happens to exist.
|
||||
throw new FailureActionException(
|
||||
FailureActionException.Reason.EVENT_NOT_FOUND, "No such event: " + eventId);
|
||||
}
|
||||
FileRunEvent event =
|
||||
store.find(eventId, scope.teamId())
|
||||
// Reported as "no such event" rather than a refusal, so a member cannot
|
||||
// learn that a colleague's incident exists by trying to close it.
|
||||
.filter(
|
||||
found ->
|
||||
scope.actor() == null
|
||||
|| scope.actor().equals(found.actor()))
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new FailureActionException(
|
||||
FailureActionException.Reason.EVENT_NOT_FOUND,
|
||||
"No such event: " + eventId));
|
||||
// Audience decides what is offered, not what may be dispatched, so this scope is the whole
|
||||
// gate. A server action aimed at OWNER alone would need its own guard here.
|
||||
FileRunEvent event = requireVisible(eventId);
|
||||
|
||||
FailureActionId resolvedId = parseActionId(actionId);
|
||||
|
||||
@@ -156,6 +147,12 @@ public class FileRunEventService {
|
||||
FailureActionException.Reason.ACTION_NOT_DECLARED,
|
||||
"Kind " + event.kind().getId() + " does not offer action " + resolvedId);
|
||||
}
|
||||
// Without this a client could post VIEW_FILE and be answered as though something happened.
|
||||
if (!resolvedId.runsOnServer()) {
|
||||
throw new FailureActionException(
|
||||
FailureActionException.Reason.ACTION_NOT_DISPATCHABLE,
|
||||
"Action " + resolvedId + " is run by the client, not the server");
|
||||
}
|
||||
if (event.status().terminal()) {
|
||||
throw new FailureActionException(
|
||||
FailureActionException.Reason.ALREADY_CLOSED,
|
||||
@@ -174,23 +171,91 @@ public class FileRunEventService {
|
||||
return action.execute(event, inputs == null ? Map.of() : inputs, currentActor());
|
||||
}
|
||||
|
||||
/** "No such event" rather than a refusal, so trying does not confirm a colleague's exists. */
|
||||
private FileRunEvent requireVisible(String eventId) {
|
||||
ReadScope scope = readScope();
|
||||
if (!scope.permitted()) {
|
||||
return notFound(eventId);
|
||||
}
|
||||
return store.find(eventId, scope.teamId())
|
||||
.filter(found -> scope.actor() == null || scope.actor().equals(found.actor()))
|
||||
.orElseGet(() -> notFound(eventId));
|
||||
}
|
||||
|
||||
private FileRunEvent notFound(String eventId) {
|
||||
throw new FailureActionException(
|
||||
FailureActionException.Reason.EVENT_NOT_FOUND, "No such event: " + eventId);
|
||||
}
|
||||
|
||||
public Ownership ownershipOf(FileRunEvent event) {
|
||||
if (event.actor() == null) {
|
||||
return Ownership.UNOWNED;
|
||||
}
|
||||
String caller = currentActor();
|
||||
return event.actor().equals(caller) ? Ownership.MINE : Ownership.THEIRS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of an event's declared actions are usable right now. Decided per row, so the client
|
||||
* never renders a button that would be refused.
|
||||
* Offers resolved for one caller, so no client renders a button that would be refused. Outside
|
||||
* their audience is dropped, not disabled: greyed out would read as a permission problem.
|
||||
*/
|
||||
public List<AvailableAction> availableActions(FileRunEvent event) {
|
||||
Ownership ownership = ownershipOf(event);
|
||||
boolean reviewsTeam = reviewsTeam();
|
||||
boolean closed = event.status().terminal();
|
||||
return event.kind().getActions().stream()
|
||||
.map(
|
||||
action ->
|
||||
new AvailableAction(
|
||||
action,
|
||||
event.kind().labelKeyFor(action),
|
||||
!closed,
|
||||
closed ? "portal.failures.disabled.closed" : null))
|
||||
// Login disabled is excluded: its rows are unowned only for want of users, and its one
|
||||
// operator owns everything they can see.
|
||||
boolean unattended = enforced() && ownership == Ownership.UNOWNED;
|
||||
// Answered here, or the client reports "not on this device" about a document the row never
|
||||
// identified in the first place.
|
||||
boolean documentless = event.fileId() == null || event.fileId().isBlank();
|
||||
return event.kind().getOfferedActions().stream()
|
||||
.filter(offer -> offeredTo(offer.audience(), ownership, reviewsTeam))
|
||||
.map(offer -> availability(offer, closed, unattended, documentless))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Enabled is derived from the reason, so a disabled button always has one to show. */
|
||||
private static AvailableAction availability(
|
||||
FailureKind.OfferedAction offer,
|
||||
boolean closed,
|
||||
boolean unattended,
|
||||
boolean documentless) {
|
||||
String reason = disabledReasonFor(offer.audience(), closed, unattended, documentless);
|
||||
return new AvailableAction(offer.id(), offer.labelKey(), reason == null, reason);
|
||||
}
|
||||
|
||||
/** Closed wins over everything, then the owner-only reasons, most specific first. */
|
||||
private static String disabledReasonFor(
|
||||
FailureAudience audience, boolean closed, boolean unattended, boolean documentless) {
|
||||
if (closed) {
|
||||
return CLOSED_REASON_KEY;
|
||||
}
|
||||
if (audience != FailureAudience.OWNER) {
|
||||
return null;
|
||||
}
|
||||
if (unattended) {
|
||||
return UNATTENDED_REASON_KEY;
|
||||
}
|
||||
return documentless ? DOCUMENTLESS_REASON_KEY : null;
|
||||
}
|
||||
|
||||
/** An unattended incident has no owner, so its reviewer inherits the owner's actions. */
|
||||
private static boolean offeredTo(
|
||||
FailureAudience audience, Ownership ownership, boolean reviewsTeam) {
|
||||
return switch (audience) {
|
||||
case OWNER ->
|
||||
ownership == Ownership.MINE || (ownership == Ownership.UNOWNED && reviewsTeam);
|
||||
case TEAM_REVIEWER -> reviewsTeam;
|
||||
case ANYONE_WHO_SEES -> true;
|
||||
};
|
||||
}
|
||||
|
||||
/** Login disabled has no roles, so its one operator triages everything. */
|
||||
private boolean reviewsTeam() {
|
||||
return !enforced() || policyManagementAuthority.canEditPolicies();
|
||||
}
|
||||
|
||||
private FailureActionId parseActionId(String actionId) {
|
||||
for (FailureActionId candidate : FailureActionId.values()) {
|
||||
if (candidate.name().equals(actionId)) {
|
||||
@@ -261,7 +326,6 @@ public class FileRunEventService {
|
||||
return applicationProperties.getSecurity().isEnableLogin();
|
||||
}
|
||||
|
||||
/** One action as offered for a specific event, with its resolved availability. */
|
||||
public record AvailableAction(
|
||||
FailureActionId id, String labelKey, boolean enabled, String disabledReasonKey) {}
|
||||
}
|
||||
|
||||
+13
-3
@@ -60,14 +60,24 @@ public record FileRunEventView(
|
||||
event.lastSeenAt() == null ? 0L : event.lastSeenAt().toEpochMilli());
|
||||
}
|
||||
|
||||
/** One button, as offered for this specific row. */
|
||||
/**
|
||||
* {@code defaultLabel} and {@code execution} let a client render and route an action it was
|
||||
* never built with. Declaration order is display order.
|
||||
*/
|
||||
public record ActionView(
|
||||
String id, String labelKey, boolean enabled, String disabledReasonKey) {
|
||||
String id,
|
||||
String labelKey,
|
||||
String defaultLabel,
|
||||
FailureActionId.Execution execution,
|
||||
boolean enabled,
|
||||
String disabledReasonKey) {
|
||||
|
||||
static ActionView of(FileRunEventService.AvailableAction action) {
|
||||
public static ActionView of(FileRunEventService.AvailableAction action) {
|
||||
return new ActionView(
|
||||
action.id().name(),
|
||||
action.labelKey(),
|
||||
action.id().getDefaultLabel(),
|
||||
action.id().getExecution(),
|
||||
action.enabled(),
|
||||
action.disabledReasonKey());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
/**
|
||||
* Whose incident this is, from the reader's point of view. Derived on read, never persisted: one
|
||||
* row is {@code MINE} to whoever hit it and {@code THEIRS} to the leader reviewing after them.
|
||||
*/
|
||||
public enum Ownership {
|
||||
MINE,
|
||||
|
||||
/** A colleague's, visible because the caller reviews the team. */
|
||||
THEIRS,
|
||||
|
||||
/**
|
||||
* An unattended run: a folder, bucket or webhook is its only attribution, so there is no owner.
|
||||
*/
|
||||
UNOWNED
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package stirling.software.proprietary.notification;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Open to any authenticated user, unlike the failure endpoints it draws on: each source scopes its
|
||||
* own rows. Read-only, because every action a notification offers runs on the client's own device.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/notifications")
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Notifications", description = "Things worth telling the caller about")
|
||||
public class NotificationController {
|
||||
|
||||
/** How many notifications one read returns when the caller does not say: one panelful. */
|
||||
private static final int DEFAULT_LIMIT = 20;
|
||||
|
||||
/** The most one read may return however large a limit the caller asks for. */
|
||||
private static final int MAX_LIMIT = 100;
|
||||
|
||||
private final NotificationService notifications;
|
||||
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "List the caller's notifications",
|
||||
description =
|
||||
"Newest first. Derived from the sources that produce them, so there is nothing"
|
||||
+ " to mark read here yet: the client tracks what it has shown.")
|
||||
public NotificationsResponse list(@RequestParam(required = false) Integer limit) {
|
||||
int capped = Math.min(limit == null ? DEFAULT_LIMIT : Math.max(1, limit), MAX_LIMIT);
|
||||
return new NotificationsResponse(notifications.list(capped));
|
||||
}
|
||||
|
||||
/** Wrapped so paging or a total can be added without breaking clients. */
|
||||
public record NotificationsResponse(List<NotificationView> notifications) {}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package stirling.software.proprietary.notification;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.failure.FileRunEvent;
|
||||
import stirling.software.proprietary.failure.FileRunEventService;
|
||||
import stirling.software.proprietary.failure.FileRunEventView;
|
||||
|
||||
/**
|
||||
* Derived on read rather than stored: one source today, and a table would need a write path,
|
||||
* retention and a per-user read model first. Each source scopes its own rows, so this cannot widen.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class NotificationService {
|
||||
|
||||
private final FileRunEventService fileRunEvents;
|
||||
|
||||
/** Newest first, and only open failures: one already dealt with is not news. */
|
||||
public List<NotificationView> list(int limit) {
|
||||
return fileRunEvents.list(null, null, limit).stream().map(this::fromFailure).toList();
|
||||
}
|
||||
|
||||
/** Prefixes the row id on the way out, so it is never sent bare. */
|
||||
private NotificationView fromFailure(FileRunEvent event) {
|
||||
return new NotificationView(
|
||||
NotificationSource.FAILURE.qualify(event.id()),
|
||||
NotificationSource.FAILURE,
|
||||
event.kind().getId(),
|
||||
event.origin(),
|
||||
fileRunEvents.ownershipOf(event),
|
||||
event.severity(),
|
||||
event.status(),
|
||||
event.kind().getTitleKey(),
|
||||
event.kind().getDefaultTitle(),
|
||||
event.detail(),
|
||||
event.fileId(),
|
||||
event.sourceId(),
|
||||
event.policyId(),
|
||||
event.occurrences(),
|
||||
event.createdAt(),
|
||||
event.lastSeenAt(),
|
||||
// A disposition such as Dismiss belongs to the review surface, not the bell.
|
||||
fileRunEvents.availableActions(event).stream()
|
||||
.filter(action -> !action.id().runsOnServer())
|
||||
.map(FileRunEventView.ActionView::of)
|
||||
.toList());
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package stirling.software.proprietary.notification;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Which subsystem produced a notification. Every id is prefixed with it, so a client never holds
|
||||
* the producing row's own id and cannot reach that source's endpoints by accident.
|
||||
*/
|
||||
public enum NotificationSource {
|
||||
FAILURE;
|
||||
|
||||
private static final char SEPARATOR = ':';
|
||||
|
||||
public String prefix() {
|
||||
return name().toLowerCase(Locale.ROOT) + SEPARATOR;
|
||||
}
|
||||
|
||||
public String qualify(String sourceRowId) {
|
||||
return prefix() + sourceRowId;
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package stirling.software.proprietary.notification;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import stirling.software.proprietary.failure.FailureOrigin;
|
||||
import stirling.software.proprietary.failure.FailureSeverity;
|
||||
import stirling.software.proprietary.failure.FileRunEventStatus;
|
||||
import stirling.software.proprietary.failure.FileRunEventView;
|
||||
import stirling.software.proprietary.failure.Ownership;
|
||||
|
||||
/**
|
||||
* A source's row flattened to what a bell renders. {@code fileId} is an opaque reference, never a
|
||||
* name, and two id spaces share it: {@code sourceId} tells them apart.
|
||||
*/
|
||||
public record NotificationView(
|
||||
String id,
|
||||
NotificationSource source,
|
||||
String kindId,
|
||||
FailureOrigin origin,
|
||||
Ownership ownership,
|
||||
FailureSeverity severity,
|
||||
FileRunEventStatus status,
|
||||
String titleKey,
|
||||
String defaultTitle,
|
||||
String detail,
|
||||
String fileId,
|
||||
String sourceId,
|
||||
String policyId,
|
||||
int occurrences,
|
||||
Instant createdAt,
|
||||
Instant lastSeenAt,
|
||||
List<FileRunEventView.ActionView> actions) {}
|
||||
+24
-2
@@ -576,7 +576,9 @@ public class PolicyController {
|
||||
+ " under 'fileInput', supporting files under 'assets[i].key' /"
|
||||
+ " 'assets[i].file' - only for bindings the policy does not already"
|
||||
+ " store). Runs regardless of the policy's enabled flag, which only"
|
||||
+ " gates automatic triggering. Returns a run id.")
|
||||
+ " gates automatic triggering. A single-document run may also send its"
|
||||
+ " own opaque 'fileId', which is recorded against any failure so the"
|
||||
+ " caller can resolve it back to that document. Returns a run id.")
|
||||
public ResponseEntity<JobResponse<Void>> runStoredPolicy(
|
||||
@PathVariable String policyId, @Valid @ModelAttribute PolicyRunFiles files)
|
||||
throws IOException {
|
||||
@@ -590,7 +592,14 @@ public class PolicyController {
|
||||
HttpStatus.NOT_FOUND, "No policy: " + policyId));
|
||||
stampPolicyAudit(policy.toDefinition());
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId();
|
||||
String runId =
|
||||
policyRunner
|
||||
.runWith(
|
||||
policy,
|
||||
inputs,
|
||||
PolicyProgressListener.NOOP,
|
||||
documentReferenceFor(files, inputs))
|
||||
.runId();
|
||||
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
|
||||
}
|
||||
|
||||
@@ -722,6 +731,19 @@ public class PolicyController {
|
||||
return new PolicyInputs(primary, supportingFiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only for a single-document run: an incident holds one file reference, so naming one of
|
||||
* several would attribute the failure to whichever bound first. Counted off resolved inputs,
|
||||
* not parts.
|
||||
*/
|
||||
private static String documentReferenceFor(PolicyRunFiles files, PolicyInputs inputs) {
|
||||
String fileId = files.getFileId();
|
||||
if (fileId == null || fileId.isBlank() || inputs.primary().size() != 1) {
|
||||
return null;
|
||||
}
|
||||
return fileId;
|
||||
}
|
||||
|
||||
private PolicyProgressListener streamListener(SseEmitter emitter) {
|
||||
return new PolicyProgressListener() {
|
||||
@Override
|
||||
|
||||
+14
-2
@@ -16,8 +16,8 @@ import lombok.Data;
|
||||
* from the multipart request via {@code @ModelAttribute}; the pipeline definition itself travels as
|
||||
* a separate typed {@code json} part.
|
||||
*
|
||||
* <p>Wire form: {@code fileInput} (repeated) for primaries, and {@code assets[i].key} / {@code
|
||||
* assets[i].file} for each supporting asset.
|
||||
* <p>Wire form: {@code fileInput} (repeated) for primaries, {@code assets[i].key} / {@code
|
||||
* assets[i].file} for each supporting asset, and the optional {@code fileId}.
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "Files for a policy run: primary documents plus keyed supporting assets")
|
||||
@@ -29,4 +29,16 @@ public class PolicyRunFiles {
|
||||
@Valid
|
||||
@Schema(description = "Supporting files, each bound to the asset key its step references")
|
||||
private List<NamedAsset> assets = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Recorded against any failure of this run, so the client can resolve the row back to its
|
||||
* document. Opaque by contract, never a name, and only honoured for a single-document run.
|
||||
*/
|
||||
@Schema(
|
||||
description =
|
||||
"The caller's opaque id for the document being run, echoed onto any failure"
|
||||
+ " recorded for this run so the originating client can resolve it."
|
||||
+ " Ignored unless exactly one primary document is supplied. Never a"
|
||||
+ " filename.")
|
||||
private String fileId;
|
||||
}
|
||||
|
||||
+2
-1
@@ -151,7 +151,8 @@ public class PolicyEngine {
|
||||
* As {@link #runPolicy(Policy, PolicyInputs, PolicyProgressListener)}, recording which source
|
||||
* fed the run and its opaque reference to the document. The first says where an unattended
|
||||
* failure came from; the second says which document, and is what lets the same document failing
|
||||
* again fold into one incident. Both null for a user's upload.
|
||||
* again fold into one incident. With no source {@code fileIdentity} is the client's own
|
||||
* reference, with one it is that source's hash; this engine only carries it either way.
|
||||
*/
|
||||
public PolicyRunHandle runPolicy(
|
||||
Policy policy,
|
||||
|
||||
+9
-2
@@ -120,10 +120,17 @@ public class PolicyRunner {
|
||||
* Run a stored policy on caller-supplied files (e.g. an editor upload), bypassing its sources.
|
||||
* The supplied documents are still counted against the virtual {@link EditorSource}, scoped to
|
||||
* the policy's team, so the Sources overview reports the whole team's editor throughput.
|
||||
*
|
||||
* @param documentReference the caller's own opaque reference to the single document it runs on,
|
||||
* or null when it supplied none or several. Passed through untouched.
|
||||
*/
|
||||
public PolicyRunHandle runWith(
|
||||
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
|
||||
PolicyRunHandle handle = policyEngine.runPolicy(policy, inputs, listener);
|
||||
Policy policy,
|
||||
PolicyInputs inputs,
|
||||
PolicyProgressListener listener,
|
||||
String documentReference) {
|
||||
PolicyRunHandle handle =
|
||||
policyEngine.runPolicy(policy, inputs, listener, null, documentReference);
|
||||
docCounter.record(EditorSource.counterKey(policy.teamId()), inputs.primary().size());
|
||||
return handle;
|
||||
}
|
||||
|
||||
+67
-27
@@ -21,9 +21,9 @@ import org.mockito.ArgumentCaptor;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms register
|
||||
* relays the JWT and parses the credential, and that entitlement parsing + the fail-open (null on
|
||||
* unreachable) behaviour hold.
|
||||
* Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms the connect
|
||||
* handshake refuses an authorize URL it would not navigate to and carries no user token, and that
|
||||
* entitlement parsing + the fail-open (null on unreachable) behaviour hold.
|
||||
*/
|
||||
class AccountLinkClientTest {
|
||||
|
||||
@@ -48,39 +48,79 @@ class AccountLinkClientTest {
|
||||
return resp;
|
||||
}
|
||||
|
||||
// register() is gone with the JWT relay, and with it the two tests that asserted this client
|
||||
// sends an Authorization: Bearer header. Nothing here carries a user token any more.
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void registerRelaysJwtAndParsesCredential() throws Exception {
|
||||
// Build the stub response first: nesting response() inside when() trips Mockito's
|
||||
// unfinished-stubbing check (inner when() runs mid outer when()).
|
||||
void connectRequestRefusesAnAuthorizeUrlItWouldNotNavigateTo() throws Exception {
|
||||
// The reply drives a browser navigation, so a non-absolute or non-http(s) value must fail
|
||||
// loudly here rather than reach the admin.
|
||||
HttpResponse<String> resp =
|
||||
response(201, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":42}");
|
||||
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
|
||||
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
|
||||
.thenReturn(resp);
|
||||
response(201, "{\"requestId\":\"req-1\",\"authorizeUrl\":\"/link?request=req-1\"}");
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||
|
||||
AccountLinkClient.RegisterResult result = client.register("jwt-token", "My Server");
|
||||
|
||||
assertEquals("dev-1", result.deviceId());
|
||||
assertEquals("sec-1", result.deviceSecret());
|
||||
assertEquals(42L, result.teamId());
|
||||
|
||||
HttpRequest sent = captor.getValue();
|
||||
assertEquals("Bearer jwt-token", sent.headers().firstValue("Authorization").orElse(null));
|
||||
assertEquals(
|
||||
"https://saas.example.com/api/v1/account-link/register", sent.uri().toString());
|
||||
assertThrows(
|
||||
java.io.IOException.class,
|
||||
() -> client.connectRequest("n", "https://pdf.example.com/cb", "nonce", "secret"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void registerThrowsUpstreamExceptionWithStatusOnNon2xx() throws Exception {
|
||||
HttpResponse<String> resp = response(401, "{\"error\":\"unauthorized\"}");
|
||||
void connectRequestParsesTheAuthorizeUrlItIsGiven() throws Exception {
|
||||
HttpResponse<String> resp =
|
||||
response(
|
||||
201,
|
||||
"{\"requestId\":\"req-1\",\"expiresIn\":900,"
|
||||
+ "\"authorizeUrl\":\"https://app.example.com/link?request=req-1\"}");
|
||||
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
|
||||
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
|
||||
.thenReturn(resp);
|
||||
|
||||
AccountLinkClient.ConnectRequestResult result =
|
||||
client.connectRequest("n", "https://pdf.example.com/cb", "nonce", "secret");
|
||||
|
||||
assertEquals("req-1", result.requestId());
|
||||
assertEquals("https://app.example.com/link?request=req-1", result.authorizeUrl());
|
||||
// No user token on this call, by design.
|
||||
assertEquals(null, captor.getValue().headers().firstValue("Authorization").orElse(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void connectClaimGrantsTheCredentialOnSuccess() throws Exception {
|
||||
HttpResponse<String> resp =
|
||||
response(200, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":7}");
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||
AccountLinkClient.UpstreamException ex =
|
||||
assertThrows(
|
||||
AccountLinkClient.UpstreamException.class,
|
||||
() -> client.register("jwt", null));
|
||||
assertEquals(401, ex.status());
|
||||
|
||||
AccountLinkClient.ConnectClaimResult result = client.connectClaim("req-1", "secret");
|
||||
|
||||
assertEquals(AccountLinkClient.ConnectClaimOutcome.GRANTED, result.outcome());
|
||||
assertEquals("dev-1", result.deviceId());
|
||||
assertEquals("sec-1", result.deviceSecret());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void connectClaimMapsTheStatusItIsGiven() throws Exception {
|
||||
// The whole point of these four: a claim consumes the request server-side, so
|
||||
// reading 200 as anything but success loses the credential irrecoverably.
|
||||
assertEquals(AccountLinkClient.ConnectClaimOutcome.PENDING, claimOutcome(202, "{}"));
|
||||
assertEquals(AccountLinkClient.ConnectClaimOutcome.UNAVAILABLE, claimOutcome(503, "{}"));
|
||||
assertEquals(AccountLinkClient.ConnectClaimOutcome.REJECTED, claimOutcome(400, "{}"));
|
||||
assertEquals(
|
||||
AccountLinkClient.ConnectClaimOutcome.CONFIRMED,
|
||||
claimOutcome(200, "{\"status\":\"confirmed\",\"teamId\":7}"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private AccountLinkClient.ConnectClaimOutcome claimOutcome(int status, String body)
|
||||
throws Exception {
|
||||
// Built before the when(), not inside it: response() stubs a mock of its own, and
|
||||
// Mockito cannot have that happen mid-stubbing.
|
||||
HttpResponse<String> resp = response(status, body);
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||
return client.connectClaim("req-1", "secret").outcome();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+40
-33
@@ -1,6 +1,7 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -14,16 +15,15 @@ import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.proprietary.accountlink.AccountLinkController.LinkRequest;
|
||||
|
||||
/**
|
||||
* The local (self-hosted) account-link controller's error mapping: an upstream auth rejection
|
||||
* surfaces as 401/403 (so the portal can prompt a re-sign-in) while other upstream / transport
|
||||
* faults are a 502.
|
||||
* The local (self-hosted) account-link controller's error mapping. Every upstream or transport
|
||||
* failure is a 502, and the response body never echoes the exception, because a DNS or TLS message
|
||||
* can carry the configured SaaS host.
|
||||
*/
|
||||
class AccountLinkControllerTest {
|
||||
|
||||
private AccountLinkService service;
|
||||
private ConnectService connectService;
|
||||
private UsageSyncService syncService;
|
||||
private ObjectProvider<UsageSyncService> syncProvider;
|
||||
private AccountLinkController controller;
|
||||
@@ -32,47 +32,54 @@ class AccountLinkControllerTest {
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() {
|
||||
service = mock(AccountLinkService.class);
|
||||
connectService = mock(ConnectService.class);
|
||||
syncService = mock(UsageSyncService.class);
|
||||
syncProvider = mock(ObjectProvider.class);
|
||||
controller =
|
||||
new AccountLinkController(service, mock(LocalUsageService.class), syncProvider);
|
||||
new AccountLinkController(
|
||||
service, connectService, mock(LocalUsageService.class), syncProvider);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_missingJwt_returns400() {
|
||||
ResponseEntity<?> resp = controller.link(new LinkRequest(" ", null));
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
// These asserted POST /link's error mapping, which distinguished 401/403 so the portal could
|
||||
// prompt a re-sign-in. That endpoint is gone with the JWT relay, and the distinction went with
|
||||
// it: connect/start carries no user token, so an upstream refusal is never the admin's session
|
||||
// and everything non-transport is a plain gateway failure.
|
||||
|
||||
@Test
|
||||
void link_upstreamUnauthorized_maps401() throws Exception {
|
||||
when(service.link("jwt", null))
|
||||
.thenThrow(new AccountLinkClient.UpstreamException(401, "bad token"));
|
||||
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_upstreamForbidden_maps403() throws Exception {
|
||||
when(service.link("jwt", null))
|
||||
.thenThrow(new AccountLinkClient.UpstreamException(403, "forbidden"));
|
||||
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_upstreamServerError_maps502() throws Exception {
|
||||
when(service.link("jwt", null))
|
||||
void connectStart_upstreamFailure_maps502() throws Exception {
|
||||
when(connectService.start(any(), any()))
|
||||
.thenThrow(new AccountLinkClient.UpstreamException(500, "boom"));
|
||||
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||
|
||||
ResponseEntity<?> resp = controller.connectStart(null, request());
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_transportFailure_maps502() throws Exception {
|
||||
when(service.link("jwt", null)).thenThrow(new IOException("connection refused"));
|
||||
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||
void connectStart_transportFailure_maps502WithoutLeakingTheHost() throws Exception {
|
||||
when(connectService.start(any(), any()))
|
||||
.thenThrow(new IOException("connection refused to saas.internal:8081"));
|
||||
|
||||
ResponseEntity<?> resp = controller.connectStart(null, request());
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
|
||||
// The body must not echo the exception: a DNS/TLS message can carry the configured SaaS
|
||||
// host.
|
||||
assertThat(String.valueOf(resp.getBody())).doesNotContain("saas.internal");
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectReauth_onAnUnlinkedServer_maps502() throws Exception {
|
||||
when(connectService.startReauth(any())).thenThrow(new IOException("not linked"));
|
||||
|
||||
ResponseEntity<?> resp = controller.connectReauth(null, request());
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
|
||||
/** Minimal request: the controller only reads Origin and the forwarded/host details from it. */
|
||||
private static jakarta.servlet.http.HttpServletRequest request() {
|
||||
return new org.springframework.mock.web.MockHttpServletRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+6
-16
@@ -3,12 +3,10 @@ package stirling.software.proprietary.accountlink;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -30,33 +28,25 @@ class AccountLinkServiceTest {
|
||||
service = new AccountLinkService(client, store, cache);
|
||||
}
|
||||
|
||||
// The two link() tests here are gone with the JWT relay. Storing a credential and invalidating
|
||||
// the entitlement cache is now ConnectService's job and is covered by ConnectServiceTest; what
|
||||
// remains in this service is status and unlink.
|
||||
|
||||
@Test
|
||||
void link_storesCredentialAndInvalidatesCache() throws IOException {
|
||||
when(client.register("jwt", "name"))
|
||||
.thenReturn(new AccountLinkClient.RegisterResult("dev-1", "sec-1", 7L));
|
||||
void status_linkedFromTheStoredCredential() {
|
||||
DeviceCredential stored = new DeviceCredential();
|
||||
stored.setDeviceId("dev-1");
|
||||
stored.setTeamId(7L);
|
||||
stored.setLinkedAt(LocalDateTime.now());
|
||||
when(store.get()).thenReturn(Optional.of(stored));
|
||||
|
||||
AccountLinkService.LinkStatus status = service.link("jwt", "name");
|
||||
AccountLinkService.LinkStatus status = service.status();
|
||||
|
||||
verify(store).save("dev-1", "sec-1", 7L);
|
||||
verify(cache).invalidate();
|
||||
assertTrue(status.linked());
|
||||
assertEquals("dev-1", status.deviceId());
|
||||
assertEquals(7L, status.teamId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_propagatesRegisterFailure() throws IOException {
|
||||
when(client.register(any(), any())).thenThrow(new IOException("boom"));
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
IOException.class, () -> service.link("jwt", null));
|
||||
verify(cache, org.mockito.Mockito.never()).invalidate();
|
||||
}
|
||||
|
||||
@Test
|
||||
void status_unlinkedWhenNoCredential() {
|
||||
when(store.get()).thenReturn(Optional.empty());
|
||||
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
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.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectClaimOutcome;
|
||||
import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectClaimResult;
|
||||
import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectRequestResult;
|
||||
import stirling.software.proprietary.accountlink.ConnectService.Phase;
|
||||
|
||||
/** Unit tests for the instance half of the connect handshake. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ConnectServiceTest {
|
||||
|
||||
private static final String NONCE = "the-nonce";
|
||||
private static final String CLAIM_SECRET = "the-claim-secret";
|
||||
private static final String AUTHORIZE_URL = "https://app.example.com/link?request=req-1";
|
||||
|
||||
@Mock private AccountLinkClient client;
|
||||
@Mock private ConnectStateRepository stateRepo;
|
||||
@Mock private DeviceCredentialStore credentialStore;
|
||||
@Mock private EntitlementCache entitlementCache;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private ConnectService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
service =
|
||||
new ConnectService(
|
||||
client,
|
||||
stateRepo,
|
||||
credentialStore,
|
||||
entitlementCache,
|
||||
applicationProperties);
|
||||
}
|
||||
|
||||
private void configureFrontendUrl(String url) {
|
||||
applicationProperties.getSystem().setFrontendUrl(url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_advertisesTheConfiguredFrontendUrlInPreferenceToTheRequest() throws Exception {
|
||||
configureFrontendUrl("https://pdf.example.com/");
|
||||
stubCreate();
|
||||
|
||||
service.start("prod-1", fromRequest("http://10.0.0.5:8080"));
|
||||
|
||||
verify(client)
|
||||
.connectRequest(
|
||||
anyString(),
|
||||
// Trailing slash trimmed, and the request's own view ignored.
|
||||
org.mockito.ArgumentMatchers.eq(
|
||||
"https://pdf.example.com" + ConnectService.CALLBACK_PATH),
|
||||
anyString(),
|
||||
anyString(),
|
||||
// A first link carries no credential; that is what makes it a first link.
|
||||
org.mockito.ArgumentMatchers.isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_fallsBackToTheAddressTheRequestArrivedOn() throws Exception {
|
||||
stubCreate();
|
||||
|
||||
service.start(null, fromRequest("https://pdf.internal:8443/stirling"));
|
||||
|
||||
ArgumentCaptor<String> callback = ArgumentCaptor.forClass(String.class);
|
||||
verify(client).connectRequest(any(), callback.capture(), anyString(), anyString(), any());
|
||||
// Context path preserved, so a subpath deployment gets a callback that resolves.
|
||||
assertThat(callback.getValue())
|
||||
.isEqualTo("https://pdf.internal:8443/stirling" + ConnectService.CALLBACK_PATH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_withNoAddressAtAllFailsRatherThanGuessing() {
|
||||
assertThat(catchIo(() -> service.start(null, fromRequest(null))))
|
||||
.hasMessageContaining("system.frontendUrl");
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveCallback_honoursThePortalsOwnCallbackWhenTheBrowserOriginAgrees() {
|
||||
// The frontend is the only party that knows its router's base path.
|
||||
String requested = "http://localhost:5173/app/account-link/callback";
|
||||
|
||||
assertThat(
|
||||
service.resolveCallbackUrl(
|
||||
new ConnectService.CallbackHint(
|
||||
requested,
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8080")))
|
||||
.isEqualTo(requested);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveCallback_ignoresACallbackFromADifferentOrigin() {
|
||||
assertThat(
|
||||
service.resolveCallbackUrl(
|
||||
new ConnectService.CallbackHint(
|
||||
"https://evil.example.com/steal",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8080")))
|
||||
.isEqualTo("http://localhost:5173" + ConnectService.CALLBACK_PATH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveCallback_prefersTheBrowserOriginOverTheApiRequest() {
|
||||
// The whole point: :5173 is where the admin is, :8080 is where the call landed.
|
||||
assertThat(
|
||||
service.resolveCallbackUrl(
|
||||
new ConnectService.CallbackHint(
|
||||
null, "http://localhost:5173", "http://localhost:8080")))
|
||||
.isEqualTo("http://localhost:5173" + ConnectService.CALLBACK_PATH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveCallback_letsConfigurationBeatEverything() {
|
||||
configureFrontendUrl("https://pdf.example.com/");
|
||||
|
||||
assertThat(
|
||||
service.resolveCallbackUrl(
|
||||
new ConnectService.CallbackHint(
|
||||
"http://localhost:5173/account-link/callback",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8080")))
|
||||
.isEqualTo("https://pdf.example.com" + ConnectService.CALLBACK_PATH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveCallback_ignoresAnUnusableOriginHeader() {
|
||||
// "null" is what a browser sends for an opaque origin; it must not become a callback.
|
||||
assertThat(
|
||||
service.resolveCallbackUrl(
|
||||
new ConnectService.CallbackHint(
|
||||
null, "null", "http://localhost:8080")))
|
||||
.isEqualTo("http://localhost:8080" + ConnectService.CALLBACK_PATH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_sendsTheAdminWhereverSaaSSaidToSendThem() throws Exception {
|
||||
stubCreate();
|
||||
|
||||
ConnectService.ConnectStatus status =
|
||||
service.start(null, fromRequest("https://pdf.example.com"));
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.PENDING);
|
||||
// Not composed here: only the SaaS side knows where its approval page lives, so an
|
||||
// instance configuring that could only get it wrong.
|
||||
assertThat(status.authorizeUrl()).isEqualTo(AUTHORIZE_URL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_keepsTheNonceAndClaimSecretItSent() throws Exception {
|
||||
stubCreate();
|
||||
|
||||
service.start(null, fromRequest("https://pdf.example.com"));
|
||||
|
||||
ArgumentCaptor<String> nonce = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<String> secret = ArgumentCaptor.forClass(String.class);
|
||||
verify(client).connectRequest(any(), anyString(), nonce.capture(), secret.capture(), any());
|
||||
|
||||
ArgumentCaptor<ConnectState> saved = ArgumentCaptor.forClass(ConnectState.class);
|
||||
verify(stateRepo).save(saved.capture());
|
||||
assertThat(saved.getValue().getNonce()).isEqualTo(nonce.getValue());
|
||||
assertThat(saved.getValue().getClaimSecret()).isEqualTo(secret.getValue());
|
||||
// Two independent secrets, not one value used twice.
|
||||
assertThat(nonce.getValue()).isNotEqualTo(secret.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_whenAlreadyLinkedDoesNothing() throws Exception {
|
||||
when(credentialStore.isLinked()).thenReturn(true);
|
||||
when(credentialStore.get()).thenReturn(Optional.of(credential(7L)));
|
||||
|
||||
ConnectService.ConnectStatus status =
|
||||
service.start(null, fromRequest("https://pdf.example.com"));
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.LINKED);
|
||||
verifyNoInteractions(client);
|
||||
verify(stateRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_withTheRightNonceStoresTheCredentialAndClearsTheHandshake() {
|
||||
ConnectState state = openHandshake();
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
when(client.connectClaim("req-1", CLAIM_SECRET))
|
||||
.thenReturn(new ConnectClaimResult(ConnectClaimOutcome.GRANTED, "dev", "sec", 7L));
|
||||
|
||||
ConnectService.ConnectStatus status = service.complete(NONCE);
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.LINKED);
|
||||
assertThat(status.teamId()).isEqualTo(7L);
|
||||
verify(credentialStore).save("dev", "sec", 7L);
|
||||
verify(entitlementCache).invalidate();
|
||||
verify(stateRepo).delete(state);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_withAWrongNonceClaimsNothingAndLeavesTheHandshakeAlone() {
|
||||
ConnectState state = openHandshake();
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
|
||||
ConnectService.ConnectStatus status = service.complete("not-the-nonce");
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.REJECTED);
|
||||
// The important half: an unverified caller cannot cancel a legitimate handshake.
|
||||
verify(stateRepo, never()).delete(any());
|
||||
verifyNoInteractions(credentialStore);
|
||||
verify(client, never()).connectClaim(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_withNoNonceAtAllIsRejected() {
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID))
|
||||
.thenReturn(Optional.of(openHandshake()));
|
||||
|
||||
assertThat(service.complete(null).phase()).isEqualTo(Phase.REJECTED);
|
||||
verify(client, never()).connectClaim(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_whenSaaSHasNotCommittedTheApprovalKeepsTheHandshake() {
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID))
|
||||
.thenReturn(Optional.of(openHandshake()));
|
||||
when(client.connectClaim(anyString(), anyString()))
|
||||
.thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.PENDING));
|
||||
|
||||
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.PENDING);
|
||||
verify(stateRepo, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_whenSaaSIsUnreachableKeepsTheHandshakeForARetry() {
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID))
|
||||
.thenReturn(Optional.of(openHandshake()));
|
||||
when(client.connectClaim(anyString(), anyString()))
|
||||
.thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE));
|
||||
|
||||
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.UNAVAILABLE);
|
||||
verify(stateRepo, never()).delete(any());
|
||||
verifyNoInteractions(credentialStore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_whenDeclinedClearsTheHandshake() {
|
||||
ConnectState state = openHandshake();
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
when(client.connectClaim(anyString(), anyString()))
|
||||
.thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.REJECTED));
|
||||
|
||||
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.REJECTED);
|
||||
verify(stateRepo).delete(state);
|
||||
verifyNoInteractions(credentialStore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_onAnExpiredHandshakeClearsItWithoutClaiming() {
|
||||
ConnectState state = openHandshake();
|
||||
state.setExpiresAt(LocalDateTime.now().minusSeconds(1));
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
|
||||
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.EXPIRED);
|
||||
verify(stateRepo).delete(state);
|
||||
verify(client, never()).connectClaim(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startReauth_presentsTheCredentialSoSaaSCanPinTheTeam() throws Exception {
|
||||
when(credentialStore.get()).thenReturn(Optional.of(credential(7L)));
|
||||
when(client.connectRequest(any(), anyString(), anyString(), anyString(), any()))
|
||||
.thenReturn(new ConnectRequestResult("req-1", 900, AUTHORIZE_URL));
|
||||
|
||||
service.startReauth(fromRequest("https://pdf.example.com"));
|
||||
|
||||
// Sending the credential is what makes the pinning trustworthy: the team comes from
|
||||
// something only this instance holds.
|
||||
verify(client)
|
||||
.connectRequest(
|
||||
any(),
|
||||
anyString(),
|
||||
anyString(),
|
||||
anyString(),
|
||||
org.mockito.ArgumentMatchers.argThat(
|
||||
c -> c != null && "dev".equals(c.getDeviceId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void startReauth_onAnUnlinkedServerFails() {
|
||||
assertThat(catchIo(() -> service.startReauth(fromRequest("https://pdf.example.com"))))
|
||||
.hasMessageContaining("not linked");
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_onAConfirmedReauthKeepsTheExistingCredential() {
|
||||
ConnectState state = openHandshake();
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
when(client.connectClaim(anyString(), anyString()))
|
||||
.thenReturn(new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, 7L));
|
||||
|
||||
ConnectService.ConnectStatus status = service.complete(NONCE);
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.LINKED);
|
||||
assertThat(status.teamId()).isEqualTo(7L);
|
||||
// Nothing to store: a second credential would orphan the one we already hold.
|
||||
verify(credentialStore, never()).save(anyString(), anyString(), any());
|
||||
verify(stateRepo).delete(state);
|
||||
}
|
||||
|
||||
@Test
|
||||
void status_reportsNothingInFlightWhenThereIsNoHandshakeOrCredential() {
|
||||
assertThat(service.status().phase()).isEqualTo(Phase.NONE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void status_reportsAnExpiredHandshakeRatherThanOfferingAStaleLink() {
|
||||
ConnectState state = openHandshake();
|
||||
state.setExpiresAt(LocalDateTime.now().minusSeconds(1));
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
|
||||
ConnectService.ConnectStatus status = service.status();
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.EXPIRED);
|
||||
assertThat(status.authorizeUrl()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void status_countsDownWhileAHandshakeIsOpen() {
|
||||
when(stateRepo.findById(ConnectState.SINGLETON_ID))
|
||||
.thenReturn(Optional.of(openHandshake()));
|
||||
|
||||
ConnectService.ConnectStatus status = service.status();
|
||||
|
||||
assertThat(status.phase()).isEqualTo(Phase.PENDING);
|
||||
assertThat(status.secondsRemaining()).isPositive();
|
||||
assertThat(status.authorizeUrl()).isEqualTo("https://app.example.com/link?request=req-1");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/** A start with nothing but the reconstructed request URL, as a headless caller would send. */
|
||||
private static ConnectService.CallbackHint fromRequest(String derivedBaseUrl) {
|
||||
return new ConnectService.CallbackHint(null, null, derivedBaseUrl);
|
||||
}
|
||||
|
||||
private void stubCreate() throws Exception {
|
||||
// The five-argument overload: a first link passes a null credential rather than none.
|
||||
when(client.connectRequest(any(), anyString(), anyString(), anyString(), any()))
|
||||
.thenReturn(new ConnectRequestResult("req-1", 900, AUTHORIZE_URL));
|
||||
}
|
||||
|
||||
private static ConnectState openHandshake() {
|
||||
ConnectState state = new ConnectState();
|
||||
state.setId(ConnectState.SINGLETON_ID);
|
||||
state.setRequestId("req-1");
|
||||
state.setNonce(NONCE);
|
||||
state.setClaimSecret(CLAIM_SECRET);
|
||||
state.setCallbackUrl("https://pdf.example.com/account-link/callback");
|
||||
state.setAuthorizeUrl("https://app.example.com/link?request=req-1");
|
||||
state.setCreatedAt(LocalDateTime.now());
|
||||
state.setExpiresAt(LocalDateTime.now().plusMinutes(10));
|
||||
return state;
|
||||
}
|
||||
|
||||
private static DeviceCredential credential(Long teamId) {
|
||||
DeviceCredential credential = new DeviceCredential();
|
||||
credential.setDeviceId("dev");
|
||||
credential.setDeviceSecret("sec");
|
||||
credential.setTeamId(teamId);
|
||||
credential.setLinkedAt(LocalDateTime.now());
|
||||
return credential;
|
||||
}
|
||||
|
||||
/** Runs a throwing call and returns the exception, so the assertion reads in one line. */
|
||||
private static Throwable catchIo(ThrowingCall call) {
|
||||
try {
|
||||
call.run();
|
||||
throw new AssertionError("expected the call to fail");
|
||||
} catch (Exception e) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
private interface ThrowingCall {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Pins the five enums {@code file_run_events} stores behind CHECK constraints: adding a value is a
|
||||
* schema change dressed as a Java one, compiling here and failing against a real database.
|
||||
*/
|
||||
class CheckConstrainedEnumsTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("no value has been added to a CHECK-constrained column's enum")
|
||||
void everyPersistedEnumStillMatchesTheShippedCheckConstraints() {
|
||||
assertThat(names(FileRunEventStatus.values()))
|
||||
.containsExactlyInAnyOrder(
|
||||
"NEW", "ACKNOWLEDGED", "DISMISSED", "RESOLVED", "FILE_REMOVED");
|
||||
assertThat(names(FailureOrigin.values()))
|
||||
.containsExactlyInAnyOrder("TOOL", "POLICY", "PIPELINE");
|
||||
assertThat(names(FailureStage.values()))
|
||||
.containsExactlyInAnyOrder("INPUT", "INTERNAL", "OUTPUT", "BLOCKED", "NEVER_RAN");
|
||||
assertThat(names(FailureSeverity.values()))
|
||||
.containsExactlyInAnyOrder("ERROR", "WARNING", "INFO");
|
||||
assertThat(names(FailureScope.values()))
|
||||
.containsExactlyInAnyOrder("FILE", "RUN", "POLICY", "SOURCE", "SERVER");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the facets added since are derived, not stored")
|
||||
void nothingAddedToTheModelReachedTheTable() throws Exception {
|
||||
// Resolved per reader, so a column would hold the wrong answer for all but one person.
|
||||
List<Class<?>> persisted =
|
||||
Arrays.stream(FileRunEventEntity.class.getDeclaredFields())
|
||||
.filter(field -> !field.isSynthetic())
|
||||
.map(java.lang.reflect.Field::getType)
|
||||
.toList();
|
||||
|
||||
assertThat(persisted)
|
||||
.doesNotContain(
|
||||
FailureAudience.class,
|
||||
FailureActionId.class,
|
||||
FailureActionId.Execution.class,
|
||||
Ownership.class);
|
||||
// A plain varchar with no CHECK, which is what lets a new kind ship without a migration.
|
||||
assertThat(FileRunEventEntity.class.getDeclaredField("kindId").getType())
|
||||
.isEqualTo(String.class);
|
||||
}
|
||||
|
||||
private static List<String> names(Enum<?>[] values) {
|
||||
return Arrays.stream(values).map(Enum::name).toList();
|
||||
}
|
||||
}
|
||||
+94
-13
@@ -1,6 +1,9 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.ANYONE_WHO_SEES;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.OWNER;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.TEAM_REVIEWER;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
@@ -29,6 +32,13 @@ import stirling.software.common.util.ExceptionUtils;
|
||||
*/
|
||||
class FailureKindTest {
|
||||
|
||||
/** In full, so a declaration pairing the right action with the wrong audience cannot pass. */
|
||||
private static FailureKind.OfferedAction offered(
|
||||
FailureActionId id, FailureAudience audience, String labelKeySuffix) {
|
||||
return new FailureKind.OfferedAction(
|
||||
id, "portal.failures.action." + labelKeySuffix, audience);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("every kind is well formed")
|
||||
class Invariants {
|
||||
@@ -60,6 +70,27 @@ class FailureKindTest {
|
||||
assertThat(kind.getId()).matches("^[A-Z][A-Z0-9_]*$");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(FailureKind.class)
|
||||
void declaresItsActionsInTheSameOrderAsEveryOtherKind(FailureKind kind) {
|
||||
// Declaration order is display order and the first usable offer is the row's primary,
|
||||
// so
|
||||
// two kinds disagreeing would flip the solid button between rows.
|
||||
List<FailureActionId> ranking =
|
||||
List.of(
|
||||
FailureActionId.VIEW_FILE,
|
||||
FailureActionId.VIEW_IN_PROCESSOR,
|
||||
FailureActionId.DISMISS);
|
||||
|
||||
List<FailureActionId> declared = kind.getActions();
|
||||
assertThat(ranking)
|
||||
.as("%s declares an action the shared ranking does not rank", kind.getId())
|
||||
.containsAll(declared);
|
||||
assertThat(declared)
|
||||
.as("%s declares its actions out of the shared order", kind.getId())
|
||||
.isEqualTo(ranking.stream().filter(declared::contains).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void idsAreUnique() {
|
||||
Set<String> ids = new HashSet<>();
|
||||
@@ -88,6 +119,25 @@ class FailureKindTest {
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(FailureKind.class)
|
||||
void everyOfferSaysWhoItIsFor(FailureKind kind) {
|
||||
// Read per row to decide what a caller is shown, so a null would leak a button.
|
||||
for (FailureKind.OfferedAction offer : kind.getOfferedActions()) {
|
||||
assertThat(offer.audience())
|
||||
.as("%s offers %s", kind.getId(), offer.id())
|
||||
.isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(FailureKind.class)
|
||||
void offersEachActionAtMostOnce(FailureKind kind) {
|
||||
// The same action twice would be two buttons with one meaning, and labelKeyFor would
|
||||
// answer for the first.
|
||||
assertThat(kind.getActions()).doesNotHaveDuplicates();
|
||||
}
|
||||
|
||||
@Test
|
||||
void noTwoKindsClaimTheSameErrorCode() {
|
||||
// Computed independently of duplicateErrorCodes(), then checked against it: the boot
|
||||
@@ -182,10 +232,16 @@ class FailureKindTest {
|
||||
class Unknown {
|
||||
|
||||
@Test
|
||||
void offersOnlyTheActionThatClearsIt() {
|
||||
// Nothing here can be fixed, so "seen it" and "clear it" would be the same decision.
|
||||
// Offering both just asks the reviewer to press two buttons to reach one outcome.
|
||||
assertThat(FailureKind.UNKNOWN.getActions()).containsExactly(FailureActionId.DISMISS);
|
||||
void offersItsOwnerTheirDocumentAndTheRunToWhoeverReviews() {
|
||||
// Nothing here is known to be fixable, so the offers are just the places to look.
|
||||
assertThat(FailureKind.UNKNOWN.getOfferedActions())
|
||||
.containsExactly(
|
||||
offered(FailureActionId.VIEW_FILE, OWNER, "viewFile"),
|
||||
offered(
|
||||
FailureActionId.VIEW_IN_PROCESSOR,
|
||||
TEAM_REVIEWER,
|
||||
"viewInProcessor"),
|
||||
offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, "dismiss"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -238,24 +294,49 @@ class FailureKindTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void aKindWithSomethingToFixOffersTheFixAndAWayToSkipIt() {
|
||||
assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.getActions())
|
||||
.containsExactly(FailureActionId.ACKNOWLEDGE, FailureActionId.DISMISS);
|
||||
void offersTheDocumentToItsOwnerAndTheRunToItsReviewer() {
|
||||
// The point of the audiences: only the owner holds the document.
|
||||
assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.getOfferedActions())
|
||||
.containsExactly(
|
||||
offered(FailureActionId.VIEW_FILE, OWNER, "viewFile"),
|
||||
offered(
|
||||
FailureActionId.VIEW_IN_PROCESSOR,
|
||||
TEAM_REVIEWER,
|
||||
"viewInProcessor"),
|
||||
offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, "dismiss"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void overriddenLabelWinsOverTheGenericOne() {
|
||||
String label =
|
||||
FailureKind.INPUT_PASSWORD_PROTECTED.labelKeyFor(FailureActionId.DISMISS);
|
||||
assertThat(label).isEqualTo("portal.failures.action.dismissSkipFile");
|
||||
assertThat(label).isNotEqualTo(FailureKind.genericLabelKey(FailureActionId.DISMISS));
|
||||
void noKindOffersAcknowledgeAnyMore() {
|
||||
// Kept in the vocabulary for rows already ACKNOWLEDGED; offered by nothing, so
|
||||
// dispatchable by nothing.
|
||||
for (FailureKind kind : FailureKind.values()) {
|
||||
assertThat(kind.declares(FailureActionId.ACKNOWLEDGE))
|
||||
.as("%s offers ACKNOWLEDGE", kind.getId())
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void genericLabelIsUsedWhenAKindDeclaresNoOverride() {
|
||||
void everyKindLabelsItsActionsWithTheSharedWordingToday() {
|
||||
// The per-kind override still exists for wording that reads badly in context.
|
||||
for (FailureKind kind : FailureKind.values()) {
|
||||
for (FailureActionId action : kind.getActions()) {
|
||||
assertThat(kind.labelKeyFor(action))
|
||||
.isEqualTo(FailureKind.genericLabelKey(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void genericLabelIsDerivedFromTheActionId() {
|
||||
assertThat(FailureKind.UNKNOWN.labelKeyFor(FailureActionId.DISMISS))
|
||||
.isEqualTo(FailureKind.genericLabelKey(FailureActionId.DISMISS))
|
||||
.isEqualTo("portal.failures.action.dismiss");
|
||||
assertThat(
|
||||
FailureKind.INPUT_PASSWORD_PROTECTED.labelKeyFor(
|
||||
FailureActionId.VIEW_IN_PROCESSOR))
|
||||
.isEqualTo("portal.failures.action.viewInProcessor");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+59
-12
@@ -128,7 +128,9 @@ class FileRunEventControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void carriesActionsAlreadyResolvedForTheRow() {
|
||||
void carriesActionsAlreadyResolvedForTheRowAndItsReader() {
|
||||
// A leader reading a colleague's password failure: the unlock is not theirs to do,
|
||||
// so it is not in the list at all.
|
||||
given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
List<FileRunEventView.ActionView> actions =
|
||||
@@ -136,10 +138,32 @@ class FileRunEventControllerTest {
|
||||
|
||||
assertThat(actions)
|
||||
.extracting(FileRunEventView.ActionView::id)
|
||||
.containsExactlyInAnyOrder("ACKNOWLEDGE", "DISMISS");
|
||||
.containsExactly("VIEW_IN_PROCESSOR", "DISMISS");
|
||||
assertThat(actions).allMatch(FileRunEventView.ActionView::enabled);
|
||||
}
|
||||
|
||||
@Test
|
||||
void carriesEnoughForAClientToRenderAndRouteAnActionItDoesNotKnow() {
|
||||
// The English fallback, which side runs it, and where the kind wants it: everything a
|
||||
// build with no copy for a newly shipped action still needs.
|
||||
given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
assertThat(controller.list(null, null, null).events().getFirst().actions())
|
||||
.allSatisfy(
|
||||
action -> {
|
||||
assertThat(action.defaultLabel()).isNotBlank();
|
||||
assertThat(action.execution()).isNotNull();
|
||||
})
|
||||
.filteredOn(action -> "VIEW_IN_PROCESSOR".equals(action.id()))
|
||||
.singleElement()
|
||||
.satisfies(
|
||||
action -> {
|
||||
assertThat(action.execution())
|
||||
.isEqualTo(FailureActionId.Execution.CLIENT);
|
||||
assertThat(action.defaultLabel()).isEqualTo("View in processor");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void showsAClosedRowsActionsDisabledWithAReasonRatherThanHidingThem() {
|
||||
// Only visible by asking for the closed status: the default queue drops it.
|
||||
@@ -162,14 +186,18 @@ class FileRunEventControllerTest {
|
||||
void filtersByStatusAndByKind() {
|
||||
FileRunEvent locked = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked");
|
||||
given(FailureKind.UNKNOWN, TEAM, "open");
|
||||
controller.act(locked.id(), "ACKNOWLEDGE", null);
|
||||
controller.act(locked.id(), "DISMISS", null);
|
||||
|
||||
assertThat(controller.list(FileRunEventStatus.ACKNOWLEDGED, null, null).events())
|
||||
.hasSize(1);
|
||||
assertThat(controller.list(null, "INPUT_PASSWORD_PROTECTED", null).events())
|
||||
assertThat(controller.list(FileRunEventStatus.DISMISSED, null, null).events())
|
||||
.extracting(FileRunEventView::fileId)
|
||||
.containsExactly("locked");
|
||||
// Acknowledged is still open work, so it stays in the default queue.
|
||||
// A dismissed row is decided, so the default queue holds only the other one.
|
||||
assertThat(controller.list(null, null, null).events())
|
||||
.extracting(FileRunEventView::fileId)
|
||||
.containsExactly("open");
|
||||
assertThat(controller.list(null, "INPUT_PASSWORD_PROTECTED", null).events())
|
||||
.extracting(FileRunEventView::fileId)
|
||||
.isEmpty();
|
||||
assertThat(controller.list(null, "NO_SUCH_KIND", null).events()).isEmpty();
|
||||
}
|
||||
|
||||
@@ -211,12 +239,31 @@ class FileRunEventControllerTest {
|
||||
void appliesADeclaredActionAndReturnsTheUpdatedRow() {
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
FileRunEventView updated = controller.act(event.id(), "ACKNOWLEDGE", null);
|
||||
FileRunEventView updated = controller.act(event.id(), "DISMISS", null);
|
||||
|
||||
assertThat(updated.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED);
|
||||
assertThat(updated.status()).isEqualTo(FileRunEventStatus.DISMISSED);
|
||||
assertThat(updated.statusActor()).isEqualTo("reviewer@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anActionTheClientRunsIsABadRequest() {
|
||||
// Offered, and still not the server's to perform: the document is in the browser.
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
assertThat(statusOf(() -> controller.act(event.id(), "VIEW_FILE", null)))
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anActionNoKindOffersAnyMoreIsABadRequest() {
|
||||
// ACKNOWLEDGE is still in the vocabulary for the rows that carry it, and still not
|
||||
// something any kind offers, so posting it is refused rather than applied.
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
assertThat(statusOf(() -> controller.act(event.id(), "ACKNOWLEDGE", null)))
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsAnAbsentBodyBecauseTheseActionsNeedNoInput() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
@@ -238,7 +285,7 @@ class FileRunEventControllerTest {
|
||||
// 404 rather than 403, so the response does not confirm the row exists.
|
||||
FileRunEvent theirs = given(FailureKind.UNKNOWN, 99L, "f1");
|
||||
|
||||
assertThat(statusOf(() -> controller.act(theirs.id(), "ACKNOWLEDGE", null)))
|
||||
assertThat(statusOf(() -> controller.act(theirs.id(), "DISMISS", null)))
|
||||
.isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@@ -248,7 +295,7 @@ class FileRunEventControllerTest {
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
controller.act(event.id(), "DISMISS", null);
|
||||
|
||||
assertThat(statusOf(() -> controller.act(event.id(), "ACKNOWLEDGE", null)))
|
||||
assertThat(statusOf(() -> controller.act(event.id(), "DISMISS", null)))
|
||||
.isEqualTo(HttpStatus.CONFLICT);
|
||||
}
|
||||
}
|
||||
@@ -276,7 +323,7 @@ class FileRunEventControllerTest {
|
||||
|
||||
assertThat(locked.actions())
|
||||
.extracting(FailureKindView.ActionDeclaration::labelKey)
|
||||
.contains("portal.failures.action.dismissSkipFile");
|
||||
.contains("portal.failures.action.viewFile", "portal.failures.action.dismiss");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+36
-20
@@ -120,13 +120,20 @@ class FileRunEventHttpIntegrationTest {
|
||||
// Epoch millis, not an ISO string: the client renders relative times from a number.
|
||||
assertThat(row.get("lastSeenAt").isNumber()).isTrue();
|
||||
|
||||
// Resolved for this reader: a leader looking at a colleague's password failure is
|
||||
// offered the run and a way to close the row, not a password they do not have.
|
||||
JsonNode actions = row.get("actions");
|
||||
assertThat(actions).hasSize(2);
|
||||
assertThat(actions.get(0).get("id").asString()).isEqualTo("ACKNOWLEDGE");
|
||||
assertThat(actions.get(0).get("id").asString()).isEqualTo("VIEW_IN_PROCESSOR");
|
||||
assertThat(actions.get(0).get("labelKey").asString())
|
||||
.isEqualTo("portal.failures.action.acknowledge");
|
||||
.isEqualTo("portal.failures.action.viewInProcessor");
|
||||
assertThat(actions.get(0).get("defaultLabel").asString())
|
||||
.isEqualTo("View in processor");
|
||||
assertThat(actions.get(0).get("execution").asString()).isEqualTo("CLIENT");
|
||||
assertThat(actions.get(0).get("enabled").asBoolean()).isTrue();
|
||||
assertThat(actions.get(0).get("disabledReasonKey").isNull()).isTrue();
|
||||
assertThat(actions.get(1).get("id").asString()).isEqualTo("DISMISS");
|
||||
assertThat(actions.get(1).get("execution").asString()).isEqualTo("SERVER");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -154,16 +161,17 @@ class FileRunEventHttpIntegrationTest {
|
||||
void coercesQueryParametersAndFiltersOnThem() throws Exception {
|
||||
String locked = seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked", "b");
|
||||
seed(FailureKind.UNKNOWN, TEAM, "open", "a");
|
||||
post("/api/v1/file-run-events/" + locked + "/actions/ACKNOWLEDGE", "{\"inputs\":{}}");
|
||||
post("/api/v1/file-run-events/" + locked + "/actions/DISMISS", "{\"inputs\":{}}");
|
||||
|
||||
JsonNode acknowledged =
|
||||
mapper.readTree(get("/api/v1/file-run-events?status=ACKNOWLEDGED").body())
|
||||
JsonNode dismissed =
|
||||
mapper.readTree(get("/api/v1/file-run-events?status=DISMISSED").body())
|
||||
.get("events");
|
||||
assertThat(acknowledged).hasSize(1);
|
||||
assertThat(dismissed).hasSize(1);
|
||||
|
||||
JsonNode byKind =
|
||||
mapper.readTree(
|
||||
get("/api/v1/file-run-events?kindId=INPUT_PASSWORD_PROTECTED")
|
||||
get("/api/v1/file-run-events?status=DISMISSED"
|
||||
+ "&kindId=INPUT_PASSWORD_PROTECTED")
|
||||
.body())
|
||||
.get("events");
|
||||
assertThat(byKind).hasSize(1);
|
||||
@@ -269,25 +277,23 @@ class FileRunEventHttpIntegrationTest {
|
||||
String id = seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1", "boom");
|
||||
|
||||
HttpResponse<String> response =
|
||||
post(
|
||||
"/api/v1/file-run-events/" + id + "/actions/ACKNOWLEDGE",
|
||||
"{\"inputs\":{}}");
|
||||
post("/api/v1/file-run-events/" + id + "/actions/DISMISS", "{\"inputs\":{}}");
|
||||
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
JsonNode row = mapper.readTree(response.body());
|
||||
assertThat(row.get("status").asString()).isEqualTo("ACKNOWLEDGED");
|
||||
assertThat(row.get("status").asString()).isEqualTo("DISMISSED");
|
||||
assertThat(row.get("statusActor").asString()).isEqualTo(ACTOR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsAPopulatedInputsMap() throws Exception {
|
||||
// Nothing consumes inputs yet, but the shape must bind so the first action that needs
|
||||
// one (a password) does not discover a broken contract.
|
||||
// No server action consumes inputs, but the shape must still bind rather than 400, so a
|
||||
// client that posts an empty or stale map is not refused over its body.
|
||||
String id = seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1", "locked");
|
||||
|
||||
HttpResponse<String> response =
|
||||
post(
|
||||
"/api/v1/file-run-events/" + id + "/actions/ACKNOWLEDGE",
|
||||
"/api/v1/file-run-events/" + id + "/actions/DISMISS",
|
||||
"{\"inputs\":{\"password\":\"hunter2\"}}");
|
||||
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
@@ -315,15 +321,27 @@ class FileRunEventHttpIntegrationTest {
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsAnActionTheClientRunsToBadRequest() throws Exception {
|
||||
// Declared by the kind, refused here: over the wire, so a client that posts a retry
|
||||
// gets a refusal rather than a 200 implying the server did something.
|
||||
String id = seed(FailureKind.UNKNOWN, TEAM, "f1", "boom");
|
||||
|
||||
assertThat(
|
||||
post(
|
||||
"/api/v1/file-run-events/" + id + "/actions/VIEW_FILE",
|
||||
"{\"inputs\":{}}")
|
||||
.statusCode())
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsAnotherTeamsRowToNotFound() throws Exception {
|
||||
String id = seed(FailureKind.UNKNOWN, 999L, "theirs", "boom");
|
||||
|
||||
assertThat(
|
||||
post(
|
||||
"/api/v1/file-run-events/"
|
||||
+ id
|
||||
+ "/actions/ACKNOWLEDGE",
|
||||
"/api/v1/file-run-events/" + id + "/actions/DISMISS",
|
||||
"{\"inputs\":{}}")
|
||||
.statusCode())
|
||||
.isEqualTo(404);
|
||||
@@ -336,9 +354,7 @@ class FileRunEventHttpIntegrationTest {
|
||||
|
||||
assertThat(
|
||||
post(
|
||||
"/api/v1/file-run-events/"
|
||||
+ id
|
||||
+ "/actions/ACKNOWLEDGE",
|
||||
"/api/v1/file-run-events/" + id + "/actions/DISMISS",
|
||||
"{\"inputs\":{}}")
|
||||
.statusCode())
|
||||
.isEqualTo(409);
|
||||
|
||||
+289
-35
@@ -1,6 +1,7 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
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.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -60,14 +61,20 @@ class FileRunEventServiceTest {
|
||||
}
|
||||
|
||||
private FileRunEvent given(FailureKind kind, Long teamId, String fileId) {
|
||||
return givenHitBy("author@example.com", kind, teamId, fileId);
|
||||
}
|
||||
|
||||
/** As {@link #given} but naming who the incident belongs to, which decides its ownership. */
|
||||
private FileRunEvent givenHitBy(String actor, FailureKind kind, Long teamId, String fileId) {
|
||||
return store.record(
|
||||
new RecordFailure(
|
||||
kind,
|
||||
FailureOrigin.POLICY,
|
||||
teamId,
|
||||
"author@example.com",
|
||||
actor,
|
||||
"policy-1",
|
||||
"run-1",
|
||||
// Distinct per file, so a RUN-scoped kind does not fold two rows into one.
|
||||
"run-" + fileId,
|
||||
null,
|
||||
fileId,
|
||||
"detail"));
|
||||
@@ -77,11 +84,28 @@ class FileRunEventServiceTest {
|
||||
@DisplayName("acknowledge")
|
||||
class Acknowledge {
|
||||
|
||||
/**
|
||||
* No kind offers it, so it cannot be dispatched; exercised directly for rows that have it.
|
||||
*/
|
||||
private FileRunEvent acknowledge(FileRunEvent event, String actor) {
|
||||
return new AcknowledgeAction(store).execute(event, Map.of(), actor);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isNoLongerOfferedSoItCannotBeDispatched() {
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
assertThatThrownBy(() -> service.dispatch(event.id(), "ACKNOWLEDGE", Map.of()))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.ACTION_NOT_DECLARED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void movesANewEventToAcknowledgedAndStampsTheActor() {
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
FileRunEvent updated = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
|
||||
FileRunEvent updated = acknowledge(event, ACTOR);
|
||||
|
||||
assertThat(updated.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED);
|
||||
assertThat(updated.statusActor()).isEqualTo(ACTOR);
|
||||
@@ -91,16 +115,24 @@ class FileRunEventServiceTest {
|
||||
@Test
|
||||
void isANoOpWhenAlreadyAcknowledgedSoOwnershipIsNotStolen() {
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
FileRunEvent first = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
|
||||
Instant originalAt = first.statusAt();
|
||||
Instant originalAt = acknowledge(event, ACTOR).statusAt();
|
||||
|
||||
when(userService.getCurrentUsername()).thenReturn("someone-else@example.com");
|
||||
FileRunEvent second = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
|
||||
FileRunEvent second = acknowledge(event, "someone-else@example.com");
|
||||
|
||||
assertThat(second.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED);
|
||||
assertThat(second.statusActor()).isEqualTo(ACTOR);
|
||||
assertThat(second.statusAt()).isEqualTo(originalAt);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAlreadyAcknowledgedRowStaysReadableAndClosable() {
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
acknowledge(event, ACTOR);
|
||||
|
||||
assertThat(service.list(FileRunEventStatus.ACKNOWLEDGED, null, 10)).hasSize(1);
|
||||
assertThat(service.dispatch(event.id(), "DISMISS", Map.of()).status())
|
||||
.isEqualTo(FileRunEventStatus.DISMISSED);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -118,7 +150,7 @@ class FileRunEventServiceTest {
|
||||
@Test
|
||||
void closesAnAcknowledgedEvent() {
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
|
||||
new AcknowledgeAction(store).execute(event, Map.of(), ACTOR);
|
||||
|
||||
assertThat(service.dispatch(event.id(), "DISMISS", Map.of()).status())
|
||||
.isEqualTo(FileRunEventStatus.DISMISSED);
|
||||
@@ -180,7 +212,7 @@ class FileRunEventServiceTest {
|
||||
void anotherTeamsEventIsNotFound() {
|
||||
FileRunEvent theirs = given(FailureKind.UNKNOWN, 99L, "f1");
|
||||
|
||||
assertThatThrownBy(() -> service.dispatch(theirs.id(), "ACKNOWLEDGE", Map.of()))
|
||||
assertThatThrownBy(() -> service.dispatch(theirs.id(), "DISMISS", Map.of()))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND);
|
||||
@@ -188,12 +220,44 @@ class FileRunEventServiceTest {
|
||||
|
||||
@Test
|
||||
void anUnknownEventIdIsNotFound() {
|
||||
assertThatThrownBy(() -> service.dispatch("nope", "ACKNOWLEDGE", Map.of()))
|
||||
assertThatThrownBy(() -> service.dispatch("nope", "DISMISS", Map.of()))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anActionTheClientRunsIsRefusedRatherThanPretendedTo() {
|
||||
// Answering 200 would tell the client something happened when nothing did.
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
assertThatThrownBy(() -> service.dispatch(event.id(), "VIEW_FILE", Map.of()))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.ACTION_NOT_DISPATCHABLE);
|
||||
|
||||
assertThat(store.find(event.id(), TEAM).orElseThrow().status())
|
||||
.isEqualTo(FileRunEventStatus.NEW);
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyClientActionIsRefusedWhicheverKindDeclaresIt() {
|
||||
// Over the whole vocabulary, so a client action added later cannot arrive dispatchable.
|
||||
for (FailureKind kind : FailureKind.values()) {
|
||||
FileRunEvent event = given(kind, TEAM, "f-" + kind.getId());
|
||||
for (FailureActionId action : kind.getActions()) {
|
||||
if (action.runsOnServer()) {
|
||||
continue;
|
||||
}
|
||||
assertThatThrownBy(() -> service.dispatch(event.id(), action.name(), Map.of()))
|
||||
.as("%s offers %s", kind.getId(), action)
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.ACTION_NOT_DISPATCHABLE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownActionIdIsRejected() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
@@ -231,11 +295,6 @@ class FileRunEventServiceTest {
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
service.dispatch(event.id(), "DISMISS", Map.of());
|
||||
|
||||
assertThatThrownBy(() -> service.dispatch(event.id(), "ACKNOWLEDGE", Map.of()))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.ALREADY_CLOSED);
|
||||
|
||||
assertThatThrownBy(() -> service.dispatch(event.id(), "DISMISS", Map.of()))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
@@ -244,18 +303,184 @@ class FileRunEventServiceTest {
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("available actions are resolved per row")
|
||||
class Availability {
|
||||
@DisplayName("ownership is derived against whoever is reading")
|
||||
class OwnershipDerivation {
|
||||
|
||||
@Test
|
||||
void openRowOffersEveryDeclaredActionEnabled() {
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
void theCallersOwnFailureIsMine() {
|
||||
FileRunEvent mine = givenHitBy(ACTOR, FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
List<FileRunEventService.AvailableAction> actions = service.availableActions(event);
|
||||
assertThat(service.ownershipOf(mine)).isEqualTo(Ownership.MINE);
|
||||
}
|
||||
|
||||
assertThat(actions).hasSize(2);
|
||||
assertThat(actions).allMatch(FileRunEventService.AvailableAction::enabled);
|
||||
assertThat(actions).allMatch(action -> action.disabledReasonKey() == null);
|
||||
@Test
|
||||
void aColleaguesIsTheirs() {
|
||||
FileRunEvent theirs =
|
||||
givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
assertThat(service.ownershipOf(theirs)).isEqualTo(Ownership.THEIRS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnattendedRunsIsNobodys() {
|
||||
// A trigger-fired run has no user to name, so there is nobody to hand the fix to.
|
||||
FileRunEvent unattended = givenHitBy(null, FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
assertThat(service.ownershipOf(unattended)).isEqualTo(Ownership.UNOWNED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSameRowIsMineToOnePersonAndTheirsToAnother() {
|
||||
// Why it is derived: a stored answer would be wrong for everyone but one person.
|
||||
FileRunEvent event = givenHitBy(ACTOR, FailureKind.UNKNOWN, TEAM, "f1");
|
||||
assertThat(service.ownershipOf(event)).isEqualTo(Ownership.MINE);
|
||||
|
||||
when(userService.getCurrentUsername()).thenReturn("colleague@example.com");
|
||||
|
||||
assertThat(service.ownershipOf(event)).isEqualTo(Ownership.THEIRS);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("available actions are resolved per row and per reader")
|
||||
class Availability {
|
||||
|
||||
private List<FailureActionId> offeredFor(FileRunEvent event) {
|
||||
return service.availableActions(event).stream()
|
||||
.map(FileRunEventService.AvailableAction::id)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theOwnerIsOfferedTheirDocumentAndNotTheReviewersView() {
|
||||
// The document is theirs to open; the processor view is for whoever reviews the team.
|
||||
when(authority.canEditPolicies()).thenReturn(false);
|
||||
FileRunEvent mine = givenHitBy(ACTOR, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
assertThat(offeredFor(mine))
|
||||
.containsExactly(FailureActionId.VIEW_FILE, FailureActionId.DISMISS);
|
||||
assertThat(service.availableActions(mine))
|
||||
.allMatch(FileRunEventService.AvailableAction::enabled);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aReviewerReadingAColleaguesIsNotOfferedTheDocumentTheyDoNotHave() {
|
||||
// Dropped, not disabled: greyed out would read as their permission problem.
|
||||
FileRunEvent theirs =
|
||||
givenHitBy(
|
||||
"colleague@example.com",
|
||||
FailureKind.INPUT_PASSWORD_PROTECTED,
|
||||
TEAM,
|
||||
"f1");
|
||||
|
||||
assertThat(offeredFor(theirs))
|
||||
.containsExactly(FailureActionId.VIEW_IN_PROCESSOR, FailureActionId.DISMISS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aReviewerInheritsTheOwnerActionsOnAnUnattendedRow() {
|
||||
// Nobody owns it, so without the inheritance the row could only ever be dismissed.
|
||||
FileRunEvent unattended =
|
||||
givenHitBy(null, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
assertThat(offeredFor(unattended))
|
||||
.containsExactly(
|
||||
FailureActionId.VIEW_FILE,
|
||||
FailureActionId.VIEW_IN_PROCESSOR,
|
||||
FailureActionId.DISMISS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void inheritedOwnerActionsComeBackDisabledWithTheReasonWhy() {
|
||||
// No browser holds a source-fed file, so it is stated rather than offered as a dead
|
||||
// button.
|
||||
FileRunEvent unattended =
|
||||
givenHitBy(null, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
assertThat(service.availableActions(unattended))
|
||||
.filteredOn(action -> action.id() != FailureActionId.DISMISS)
|
||||
.filteredOn(action -> action.id() != FailureActionId.VIEW_IN_PROCESSOR)
|
||||
.isNotEmpty()
|
||||
.allSatisfy(
|
||||
action -> {
|
||||
assertThat(action.enabled()).isFalse();
|
||||
assertThat(action.disabledReasonKey())
|
||||
.isEqualTo("portal.failures.disabled.unattended");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void theReviewersOwnActionsStayUsableOnAnUnattendedRow() {
|
||||
FileRunEvent unattended =
|
||||
givenHitBy(null, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
assertThat(service.availableActions(unattended))
|
||||
.filteredOn(
|
||||
action ->
|
||||
action.id() == FailureActionId.DISMISS
|
||||
|| action.id() == FailureActionId.VIEW_IN_PROCESSOR)
|
||||
.hasSize(2)
|
||||
.allMatch(FileRunEventService.AvailableAction::enabled);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theOwnersActionsAreDisabledWhenTheRowNamesNoDocument() {
|
||||
// Answered here, or the client calls it "not on this device" while it sits in their
|
||||
// own workbench.
|
||||
FileRunEvent documentless =
|
||||
givenHitBy(ACTOR, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, null);
|
||||
|
||||
assertThat(service.ownershipOf(documentless)).isEqualTo(Ownership.MINE);
|
||||
assertThat(service.availableActions(documentless))
|
||||
.filteredOn(action -> action.id() != FailureActionId.DISMISS)
|
||||
.filteredOn(action -> action.id() != FailureActionId.VIEW_IN_PROCESSOR)
|
||||
.isNotEmpty()
|
||||
.allSatisfy(
|
||||
action -> {
|
||||
assertThat(action.enabled()).isFalse();
|
||||
assertThat(action.disabledReasonKey())
|
||||
.isEqualTo("portal.failures.disabled.noDocument");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRowThatNamesADocumentKeepsItsOwnerActionsUsable() {
|
||||
FileRunEvent withDocument =
|
||||
givenHitBy(ACTOR, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
assertThat(service.availableActions(withDocument))
|
||||
.isNotEmpty()
|
||||
.allMatch(FileRunEventService.AvailableAction::enabled);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMemberIsNotOfferedTheOwnerActionsOnAnUnattendedRow() {
|
||||
// The inheritance is the reviewer's: a member has no claim on a run nobody attended.
|
||||
when(authority.canEditPolicies()).thenReturn(false);
|
||||
FileRunEvent unattended =
|
||||
givenHitBy(null, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
assertThat(offeredFor(unattended)).containsExactly(FailureActionId.DISMISS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLoginDisabledOperatorKeepsTheirOwnActions() {
|
||||
// Unowned for want of users, not because nothing attended: the one operator holds the
|
||||
// file.
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSecurity().setEnableLogin(false);
|
||||
FileRunEventService unsecured =
|
||||
new FileRunEventService(
|
||||
store,
|
||||
new FailureActionRegistry(List.of(new DismissAction(store))),
|
||||
authority,
|
||||
userService,
|
||||
props);
|
||||
FileRunEvent event = givenHitBy(null, FailureKind.INPUT_PASSWORD_PROTECTED, null, "f1");
|
||||
|
||||
assertThat(unsecured.availableActions(event))
|
||||
.extracting(FileRunEventService.AvailableAction::enabled)
|
||||
.containsOnly(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -275,21 +500,14 @@ class FileRunEventServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void carriesTheKindsOverriddenLabelWhereItHasOne() {
|
||||
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
assertThat(service.availableActions(event))
|
||||
.extracting(FileRunEventService.AvailableAction::labelKey)
|
||||
.contains("portal.failures.action.dismissSkipFile");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToTheGenericLabelWhereTheKindDeclaresNoOverride() {
|
||||
void carriesTheLabelKeyForEachOffer() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
assertThat(service.availableActions(event))
|
||||
.extracting(FileRunEventService.AvailableAction::labelKey)
|
||||
.containsExactly("portal.failures.action.dismiss");
|
||||
.containsExactly(
|
||||
"portal.failures.action.viewInProcessor",
|
||||
"portal.failures.action.dismiss");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,7 +668,43 @@ class FileRunEventServiceTest {
|
||||
complete.verifyEveryDeclaredActionHasAHandler();
|
||||
|
||||
for (FailureActionId id : FailureActionId.values()) {
|
||||
assertThat(complete.find(id)).isPresent();
|
||||
// Only server actions need a handler, which is why the boot check ignores the rest.
|
||||
assertThat(complete.find(id).isPresent()).isEqualTo(id.runsOnServer());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotAskForAHandlerForAnActionTheClientRuns() {
|
||||
// Otherwise every client action would need an empty handler beside it.
|
||||
FailureActionRegistry serverOnly =
|
||||
new FailureActionRegistry(
|
||||
List.of(new AcknowledgeAction(store), new DismissAction(store)));
|
||||
|
||||
assertThatCode(serverOnly::verifyEveryDeclaredActionHasAHandler)
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesAHandlerForAnActionTheClientRuns() {
|
||||
// Dispatch refuses the id before resolving a handler, so the bean reads as live and is
|
||||
// not.
|
||||
assertThatThrownBy(() -> new FailureActionRegistry(List.of(new ClientSideAction())))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("VIEW_FILE");
|
||||
}
|
||||
|
||||
/** A handler for a client action, which is exactly what must not be registered. */
|
||||
private static final class ClientSideAction implements FailureAction {
|
||||
|
||||
@Override
|
||||
public FailureActionId id() {
|
||||
return FailureActionId.VIEW_FILE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileRunEvent execute(
|
||||
FileRunEvent event, Map<String, String> inputs, String actor) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-5
@@ -233,7 +233,7 @@ class FileRunEventStoreDbTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("closing deleted files touches only that owner's own open editor rows")
|
||||
@DisplayName("deleting a document closes every incident about it that the deleter caused")
|
||||
void markFilesRemovedIsScopedBySqlNotByTheCaller() {
|
||||
// The scoping is entirely in the JPQL, so the in-memory fake proves nothing about it:
|
||||
// it implements the same rules by hand and would agree with a wrong query.
|
||||
@@ -241,6 +241,10 @@ class FileRunEventStoreDbTest {
|
||||
store.record(
|
||||
RecordFailure.forEditor(
|
||||
FailureKind.UNKNOWN, TEAM, "owner@example.com", "f-1", "boom"));
|
||||
// Recorded by the processor, about the document they just deleted. Keying on origin left
|
||||
// these in the queue.
|
||||
FileRunEvent myPolicyRun =
|
||||
store.record(failure(FailureKind.UNKNOWN, TEAM, "owner@example.com", "f-1"));
|
||||
FileRunEvent theirs =
|
||||
store.record(
|
||||
RecordFailure.forEditor(
|
||||
@@ -253,21 +257,45 @@ class FileRunEventStoreDbTest {
|
||||
"owner@example.com",
|
||||
"f-1",
|
||||
"boom"));
|
||||
FileRunEvent fromProcessor = store.record(failure(FailureKind.UNKNOWN, TEAM, "f-1"));
|
||||
|
||||
int closed = store.markFilesRemoved(TEAM, "owner@example.com", List.of("f-1"));
|
||||
|
||||
assertThat(closed).isEqualTo(1);
|
||||
assertThat(closed).isEqualTo(2);
|
||||
assertThat(store.find(mine.id(), TEAM).orElseThrow().status())
|
||||
.isEqualTo(FileRunEventStatus.FILE_REMOVED);
|
||||
assertThat(store.find(myPolicyRun.id(), TEAM).orElseThrow().status())
|
||||
.as("their upload, their document, now deleted")
|
||||
.isEqualTo(FileRunEventStatus.FILE_REMOVED);
|
||||
assertThat(store.find(theirs.id(), TEAM).orElseThrow().status())
|
||||
.as("another person's incident about their own file")
|
||||
.isEqualTo(FileRunEventStatus.NEW);
|
||||
assertThat(store.find(otherTeam.id(), OTHER_TEAM).orElseThrow().status())
|
||||
.as("another team entirely")
|
||||
.isEqualTo(FileRunEventStatus.NEW);
|
||||
assertThat(store.find(fromProcessor.id(), TEAM).orElseThrow().status())
|
||||
.as("nothing was deleted from an editor here")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a source-fed incident survives a client naming its file id")
|
||||
void markFilesRemovedLeavesSourceFedRowsAlone() {
|
||||
// With login disabled the actor is null on both sides, so the absence of a source is all
|
||||
// that stands between a local delete and a sweep's incidents.
|
||||
FileRunEvent sweep =
|
||||
store.record(
|
||||
new RecordFailure(
|
||||
FailureKind.UNKNOWN,
|
||||
FailureOrigin.POLICY,
|
||||
null,
|
||||
null,
|
||||
"policy-1",
|
||||
"run-1",
|
||||
"src-watched-folder",
|
||||
"collides-with-a-client-id",
|
||||
"detail"));
|
||||
|
||||
int closed = store.markFilesRemoved(null, null, List.of("collides-with-a-client-id"));
|
||||
|
||||
assertThat(closed).isZero();
|
||||
assertThat(store.find(sweep.id(), null).orElseThrow().status())
|
||||
.isEqualTo(FileRunEventStatus.NEW);
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -134,7 +134,8 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository {
|
||||
Collection<FileRunEventStatus> allowedFrom) {
|
||||
int closed = 0;
|
||||
for (FileRunEventEntity entity : rows.values()) {
|
||||
if (entity.getOrigin() != FailureOrigin.TOOL
|
||||
// Mirrors the real query: scoped by the absence of a source, not by origin.
|
||||
if (entity.getSourceId() != null
|
||||
|| !sameTeam(entity, teamId)
|
||||
|| !Objects.equals(entity.getActor(), actor)
|
||||
|| entity.getFileId() == null
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.notification.NotificationController;
|
||||
import stirling.software.proprietary.notification.NotificationService;
|
||||
import stirling.software.proprietary.notification.NotificationSource;
|
||||
import stirling.software.proprietary.notification.NotificationView;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
/**
|
||||
* What the bell is given to render: never a raw event id, and only the actions the client itself
|
||||
* runs, resolved for this reader by the same service that scopes the queue.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class NotificationProjectionTest {
|
||||
|
||||
private static final Long TEAM = 7L;
|
||||
private static final String ACTOR = "reviewer@example.com";
|
||||
|
||||
@Mock private PolicyManagementAuthority authority;
|
||||
@Mock private UserServiceInterface userService;
|
||||
|
||||
private FileRunEventStore store;
|
||||
private FileRunEventService failures;
|
||||
private NotificationController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSecurity().setEnableLogin(true);
|
||||
store = new FileRunEventStore(new InMemoryFileRunEventRepository());
|
||||
failures =
|
||||
new FileRunEventService(
|
||||
store,
|
||||
new FailureActionRegistry(
|
||||
List.of(new AcknowledgeAction(store), new DismissAction(store))),
|
||||
authority,
|
||||
userService,
|
||||
props);
|
||||
controller = new NotificationController(new NotificationService(failures));
|
||||
|
||||
lenient().when(authority.currentUserTeamId()).thenReturn(TEAM);
|
||||
lenient().when(authority.canEditPolicies()).thenReturn(true);
|
||||
lenient().when(userService.getCurrentUsername()).thenReturn(ACTOR);
|
||||
}
|
||||
|
||||
private FileRunEvent given(FailureKind kind, String actor, String fileId) {
|
||||
return store.record(RecordFailure.forEditor(kind, TEAM, actor, fileId, "boom"));
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("the bell holds a prefixed id and nothing else")
|
||||
class Ids {
|
||||
|
||||
@Test
|
||||
void everyNotificationIsKeyedByItsSourceAndRowId() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1");
|
||||
|
||||
NotificationView notification = controller.list(null).notifications().getFirst();
|
||||
|
||||
assertThat(notification.id()).isEqualTo("failure:" + event.id());
|
||||
assertThat(notification.source()).isEqualTo(NotificationSource.FAILURE);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("what the bell is given to render")
|
||||
class Projection {
|
||||
|
||||
@Test
|
||||
void carriesTheKindOriginOwnershipAndTheQueuesClientActions() {
|
||||
FileRunEvent mine = given(FailureKind.INPUT_PASSWORD_PROTECTED, ACTOR, "f-1");
|
||||
|
||||
NotificationView notification = controller.list(null).notifications().getFirst();
|
||||
|
||||
assertThat(notification.kindId()).isEqualTo("INPUT_PASSWORD_PROTECTED");
|
||||
assertThat(notification.origin()).isEqualTo(FailureOrigin.TOOL);
|
||||
assertThat(notification.ownership()).isEqualTo(Ownership.MINE);
|
||||
assertThat(notification.severity()).isEqualTo(FailureSeverity.ERROR);
|
||||
assertThat(notification.status()).isEqualTo(FileRunEventStatus.NEW);
|
||||
assertThat(notification.fileId()).isEqualTo("f-1");
|
||||
assertThat(notification.policyId()).isNull();
|
||||
// How the client knows the fileId above is one of its own and worth looking up.
|
||||
assertThat(notification.sourceId()).isNull();
|
||||
assertThat(notification.defaultTitle()).isNotBlank();
|
||||
// The queue's own offers minus the server's: a bell offering different ones would lie.
|
||||
assertThat(notification.actions())
|
||||
.containsExactlyElementsOf(
|
||||
FileRunEventView.of(mine, failures.availableActions(mine))
|
||||
.actions()
|
||||
.stream()
|
||||
.filter(
|
||||
action ->
|
||||
action.execution()
|
||||
== FailureActionId.Execution.CLIENT)
|
||||
.toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void offersNoActionTheServerRunsBecauseDispositionsBelongToTheQueue() {
|
||||
// Deciding a failure's fate belongs to the review surface, not the panel.
|
||||
given(FailureKind.INPUT_PASSWORD_PROTECTED, ACTOR, "f-1");
|
||||
|
||||
assertThat(controller.list(null).notifications().getFirst().actions())
|
||||
.isNotEmpty()
|
||||
.allMatch(action -> action.execution() == FailureActionId.Execution.CLIENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void namesTheSourceThatFedAnUnattendedRunSoItsFileIdIsNotMistakenForAClientsOwn() {
|
||||
// Without the source a client looks up a hash it can never resolve and calls it
|
||||
// missing.
|
||||
store.record(
|
||||
RecordFailure.forRun(
|
||||
FailureKind.INPUT_PASSWORD_PROTECTED,
|
||||
TEAM,
|
||||
null,
|
||||
"policy-1",
|
||||
"run-1",
|
||||
"source-7",
|
||||
"hashed-identity",
|
||||
"boom"));
|
||||
|
||||
NotificationView notification = controller.list(null).notifications().getFirst();
|
||||
|
||||
assertThat(notification.sourceId()).isEqualTo("source-7");
|
||||
assertThat(notification.fileId()).isEqualTo("hashed-identity");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aColleaguesNotificationOffersTheReviewersActionsOnly() {
|
||||
// A leader sees the team's failures, so audience filtering has to reach the bell too.
|
||||
given(FailureKind.INPUT_PASSWORD_PROTECTED, "colleague@example.com", "f-1");
|
||||
|
||||
assertThat(controller.list(null).notifications().getFirst().actions())
|
||||
.extracting(FileRunEventView.ActionView::id)
|
||||
.containsExactly("VIEW_IN_PROCESSOR");
|
||||
}
|
||||
|
||||
@Test
|
||||
void carriesWhatAClientNeedsToRenderAnActionItDoesNotKnow() {
|
||||
given(FailureKind.INPUT_PASSWORD_PROTECTED, ACTOR, "f-1");
|
||||
|
||||
assertThat(controller.list(null).notifications().getFirst().actions())
|
||||
.isNotEmpty()
|
||||
.allSatisfy(
|
||||
action -> {
|
||||
assertThat(action.labelKey()).startsWith("portal.failures.action.");
|
||||
assertThat(action.defaultLabel()).isNotBlank();
|
||||
assertThat(action.execution()).isNotNull();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.service.JobQueue;
|
||||
import stirling.software.common.service.ResourceMonitor;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
import stirling.software.common.service.ToolMetadataService;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
import stirling.software.proprietary.policy.asset.InProcessPolicyAssetStore;
|
||||
import stirling.software.proprietary.policy.asset.PolicyAssetResolver;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.policy.engine.PolicyEngine;
|
||||
import stirling.software.proprietary.policy.engine.PolicyExecutor;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.output.InlineOutputSink;
|
||||
import stirling.software.proprietary.policy.output.PolicyOutputResolver;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
import stirling.software.proprietary.policy.source.InProcessSourceStore;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* What a reader is offered on a real recorded row, every collaborator being the real one. Both
|
||||
* directions are asserted: offered to the wrong reader is either a dead button or a leaked
|
||||
* document.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PolicyFailureOwnershipTest {
|
||||
|
||||
private static final String ROTATE = "/api/v1/general/rotate-pdf";
|
||||
private static final Long TEAM = 3L;
|
||||
|
||||
@Mock private InternalApiClient internalApiClient;
|
||||
@Mock private ToolMetadataService toolMetadataService;
|
||||
@Mock private TaskManager taskManager;
|
||||
@Mock private FileStorage fileStorage;
|
||||
@Mock private JobOwnershipService jobOwnershipService;
|
||||
@Mock private ResourceMonitor resourceMonitor;
|
||||
@Mock private JobQueue jobQueue;
|
||||
@Mock private PolicyStore policyStore;
|
||||
@Mock private PolicyManagementAuthority authority;
|
||||
@Mock private UserServiceInterface userService;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private PolicyEngine engine;
|
||||
private FileRunEventService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSecurity().setEnableLogin(true);
|
||||
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
|
||||
props.getSystem().getTempFileManagement().setPrefix("failure-ownership-test-");
|
||||
|
||||
FileRunEventStore store = new FileRunEventStore(new InMemoryFileRunEventRepository());
|
||||
service =
|
||||
new FileRunEventService(
|
||||
store,
|
||||
new FailureActionRegistry(
|
||||
List.of(new AcknowledgeAction(store), new DismissAction(store))),
|
||||
authority,
|
||||
userService,
|
||||
props);
|
||||
|
||||
PolicyFailureRecorder recorder =
|
||||
new PolicyFailureRecorder(
|
||||
new FailureClassifier(JsonMapper.builder().build()), store, policyStore);
|
||||
PolicyExecutor executor =
|
||||
new PolicyExecutor(
|
||||
internalApiClient,
|
||||
toolMetadataService,
|
||||
new TempFileManager(new TempFileRegistry(), props),
|
||||
JsonMapper.builder().build());
|
||||
engine =
|
||||
new PolicyEngine(
|
||||
executor,
|
||||
taskManager,
|
||||
new PolicyRunRegistry(new ApplicationProperties()),
|
||||
recorder,
|
||||
fileStorage,
|
||||
jobOwnershipService,
|
||||
List.of(new InlineOutputSink(fileStorage)),
|
||||
new PolicyOutputResolver(new InProcessSourceStore()),
|
||||
resourceMonitor,
|
||||
jobQueue,
|
||||
new PolicyAssetResolver(new InProcessPolicyAssetStore()));
|
||||
|
||||
lenient()
|
||||
.when(jobOwnershipService.createScopedJobKey(anyString()))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
lenient().when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(false);
|
||||
lenient().when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
||||
// The team is resolved from the policy, so the recorded row lands in the reader's team.
|
||||
lenient().when(policyStore.get(anyString())).thenReturn(Optional.of(sharedPolicy()));
|
||||
lenient().when(authority.currentUserTeamId()).thenReturn(TEAM);
|
||||
}
|
||||
|
||||
/** Alice's policy, shared with her team. Bob is a member of it and does not own it. */
|
||||
private static Policy sharedPolicy() {
|
||||
return new Policy(
|
||||
"p1",
|
||||
"rotate",
|
||||
"alice",
|
||||
true,
|
||||
List.of(),
|
||||
List.of(new PipelineStep(ROTATE, Map.of())),
|
||||
OutputSpec.inline(),
|
||||
TEAM);
|
||||
}
|
||||
|
||||
/** Fails the policy's single tool step as {@code triggeredBy} (null = sweep). */
|
||||
private void runAndFail(String triggeredBy, String sourceId, String fileIdentity)
|
||||
throws Exception {
|
||||
when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom"));
|
||||
if (triggeredBy != null) {
|
||||
MDC.put("auditPrincipal", triggeredBy);
|
||||
}
|
||||
try {
|
||||
engine.runPolicy(
|
||||
sharedPolicy(),
|
||||
PolicyInputs.of(List.of(pdf())),
|
||||
PolicyProgressListener.NOOP,
|
||||
sourceId,
|
||||
fileIdentity)
|
||||
.completion()
|
||||
.get(10, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
MDC.remove("auditPrincipal");
|
||||
}
|
||||
}
|
||||
|
||||
private static ByteArrayResource pdf() {
|
||||
return new ByteArrayResource("input".getBytes()) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return "input.pdf";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient because a leader's scope and an UNOWNED check both answer without asking who reads,
|
||||
* so whether the name is consulted is the behaviour under test.
|
||||
*/
|
||||
private FileRunEvent asMember(String reader) {
|
||||
lenient().when(userService.getCurrentUsername()).thenReturn(reader);
|
||||
lenient().when(authority.canEditPolicies()).thenReturn(false);
|
||||
List<FileRunEvent> visible = service.list(null, null, 10);
|
||||
return visible.isEmpty() ? null : visible.getFirst();
|
||||
}
|
||||
|
||||
/** Read as a team leader, who reviews the whole team's incidents. See {@link #asMember}. */
|
||||
private FileRunEvent asReviewer(String reader) {
|
||||
lenient().when(userService.getCurrentUsername()).thenReturn(reader);
|
||||
lenient().when(authority.canEditPolicies()).thenReturn(true);
|
||||
return service.list(null, null, 10).getFirst();
|
||||
}
|
||||
|
||||
private List<FailureActionId> offeredTo(FileRunEvent event) {
|
||||
return service.availableActions(event).stream()
|
||||
.map(FileRunEventService.AvailableAction::id)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("a non-owner runs a shared policy on their own upload")
|
||||
class AttendedByANonOwner {
|
||||
|
||||
@Test
|
||||
void theTriggeringUserHoldsItAndIsOfferedTheDocument() throws Exception {
|
||||
runAndFail("bob", null, "bob-doc-1");
|
||||
|
||||
FileRunEvent mine = asMember("bob");
|
||||
assertThat(service.ownershipOf(mine)).isEqualTo(Ownership.MINE);
|
||||
assertThat(offeredTo(mine))
|
||||
.as("he is holding the document, so opening it is his to do")
|
||||
.contains(FailureActionId.VIEW_FILE);
|
||||
assertThat(service.availableActions(mine))
|
||||
.filteredOn(action -> action.id() == FailureActionId.VIEW_FILE)
|
||||
.singleElement()
|
||||
.satisfies(action -> assertThat(action.enabled()).isTrue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void thePolicyOwnerIsNotHandedADocumentSheNeverTouched() throws Exception {
|
||||
runAndFail("bob", null, "bob-doc-1");
|
||||
|
||||
// She owns the policy and pays for the run, and still has no copy of Bob's file.
|
||||
FileRunEvent theirs = asReviewer("alice");
|
||||
assertThat(service.ownershipOf(theirs)).isEqualTo(Ownership.THEIRS);
|
||||
assertThat(offeredTo(theirs)).doesNotContain(FailureActionId.VIEW_FILE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theReviewerIsStillOfferedWhatReviewingNeeds() throws Exception {
|
||||
runAndFail("bob", null, "bob-doc-1");
|
||||
|
||||
// Not her document, still her team's incident.
|
||||
assertThat(offeredTo(asReviewer("alice")))
|
||||
.contains(FailureActionId.VIEW_IN_PROCESSOR, FailureActionId.DISMISS);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("an unattended sweep pulls a file from a source")
|
||||
class UnattendedSweep {
|
||||
|
||||
@Test
|
||||
void theRowIsOwnedByNobodySoTheReviewerInheritsTheOwnerActions() throws Exception {
|
||||
runAndFail(null, "src-watched-folder", "file-hash-1");
|
||||
|
||||
FileRunEvent unattended = asReviewer("alice");
|
||||
assertThat(service.ownershipOf(unattended)).isEqualTo(Ownership.UNOWNED);
|
||||
// No browser holds this document, so the offer is stated and disabled, not dropped.
|
||||
assertThat(offeredTo(unattended)).contains(FailureActionId.VIEW_FILE);
|
||||
assertThat(service.availableActions(unattended))
|
||||
.filteredOn(action -> action.id() == FailureActionId.VIEW_FILE)
|
||||
.singleElement()
|
||||
.satisfies(
|
||||
action -> {
|
||||
assertThat(action.enabled()).isFalse();
|
||||
assertThat(action.disabledReasonKey())
|
||||
.isEqualTo("portal.failures.disabled.unattended");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void thePolicyOwnerDoesNotInheritItAsHerOwn() throws Exception {
|
||||
// Being billed for the sweep must not become ownership: she gets these as reviewer
|
||||
// only.
|
||||
runAndFail(null, "src-watched-folder", "file-hash-1");
|
||||
|
||||
assertThat(service.ownershipOf(asReviewer("alice"))).isNotEqualTo(Ownership.MINE);
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -39,6 +39,7 @@ import tools.jackson.databind.json.JsonMapper;
|
||||
class PolicyFailureRecorderTest {
|
||||
|
||||
private static final Long TEAM = 11L;
|
||||
private static final String ACTOR = "dana@example.com";
|
||||
|
||||
@Mock private PolicyStore policyStore;
|
||||
|
||||
@@ -379,5 +380,36 @@ class PolicyFailureRecorderTest {
|
||||
|
||||
assertThat(store.list(TEAM, null, null, null, 10)).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSameDocumentFailingInTwoAttendedRunsIsOneIncident() {
|
||||
// Every upload is a new run, so with no reference the run id stands in for the document
|
||||
// and the same broken file reads as a second incident rather than a second occurrence.
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", null, "editor-file-1", ACTOR, "locked", passwordFailure());
|
||||
recorder.recordRunFailure(
|
||||
"run-2", "policy-1", null, "editor-file-1", ACTOR, "locked", passwordFailure());
|
||||
|
||||
List<FileRunEvent> events = store.list(TEAM, null, null, null, 10);
|
||||
assertThat(events).hasSize(1);
|
||||
assertThat(events.getFirst().occurrences()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoDocumentsFailingTheSameWayStaySeparateIncidents() {
|
||||
// Folding is per document, so neither row is credited with the other's occurrence.
|
||||
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
|
||||
|
||||
recorder.recordRunFailure(
|
||||
"run-1", "policy-1", null, "editor-file-1", ACTOR, "locked", passwordFailure());
|
||||
recorder.recordRunFailure(
|
||||
"run-2", "policy-1", null, "editor-file-2", ACTOR, "locked", passwordFailure());
|
||||
|
||||
assertThat(store.list(TEAM, null, null, null, 10))
|
||||
.hasSize(2)
|
||||
.allMatch(event -> event.occurrences() == 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -11,6 +11,8 @@ import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.proprietary.policy.controller.PolicyRunFiles;
|
||||
|
||||
/**
|
||||
* The privacy contract: a recorded failure carries no document identity of its own. There is no
|
||||
* name column and the dedup key is built only from opaque ids, so nothing here derives from what a
|
||||
@@ -53,6 +55,16 @@ class RecordFailurePrivacyTest {
|
||||
.doesNotContain("fileName");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theRunRequestThatSuppliesADocumentReferenceCarriesNoNameEither() {
|
||||
// The same discipline at the door as in the row: an id and nothing else, or a document name
|
||||
// reaches a table that deliberately has nowhere to put it.
|
||||
assertThat(List.of(PolicyRunFiles.class.getDeclaredFields()))
|
||||
.extracting(Field::getName)
|
||||
.contains("fileId")
|
||||
.doesNotContain("fileName", "documentName", "name");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dedupKeyIsBuiltOnlyFromOpaqueIdentifiers() {
|
||||
// Two files under the same policy hash differently (so they stay separate incidents), but
|
||||
|
||||
+70
-3
@@ -29,6 +29,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
@@ -39,6 +40,7 @@ import stirling.software.common.model.job.JobResponse;
|
||||
import stirling.software.common.model.tool.ToolDiagnostic;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
|
||||
@@ -87,7 +89,10 @@ class PolicyControllerTest {
|
||||
|
||||
@Mock private ProcessedLedger processedLedger;
|
||||
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
// Real, not mocked: the run endpoints spool uploads through it.
|
||||
private final TempFileManager tempFileManager =
|
||||
new TempFileManager(new TempFileRegistry(), new ApplicationProperties());
|
||||
|
||||
@Mock private JobOwnershipService jobOwnershipService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
@@ -700,13 +705,46 @@ class PolicyControllerTest {
|
||||
@DisplayName("runStoredPolicy")
|
||||
class RunStoredPolicy {
|
||||
|
||||
/** What an editor sends: the documents, plus its own id for a single one of them. */
|
||||
private PolicyRunFiles filesWith(String fileId, int documents) {
|
||||
PolicyRunFiles files = new PolicyRunFiles();
|
||||
files.setFileId(fileId);
|
||||
files.setFileInput(
|
||||
java.util.stream.IntStream.range(0, documents)
|
||||
.mapToObj(
|
||||
i ->
|
||||
(org.springframework.web.multipart.MultipartFile)
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"doc" + i + ".pdf",
|
||||
"application/pdf",
|
||||
("pdf-" + i).getBytes()))
|
||||
.toList());
|
||||
return files;
|
||||
}
|
||||
|
||||
private String documentReferenceOf(PolicyRunFiles files) throws Exception {
|
||||
Policy p = policy("a", 1L);
|
||||
when(policyStore.get("a")).thenReturn(Optional.of(p));
|
||||
when(policyAccessGuard.canAccess(p)).thenReturn(true);
|
||||
when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP), any()))
|
||||
.thenReturn(handle("run-9"));
|
||||
|
||||
controller.runStoredPolicy("a", files);
|
||||
|
||||
ArgumentCaptor<String> reference = ArgumentCaptor.forClass(String.class);
|
||||
verify(policyRunner)
|
||||
.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP), reference.capture());
|
||||
return reference.getValue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("runs a stored, accessible policy")
|
||||
void runsStored() throws Exception {
|
||||
Policy p = policy("a", 1L);
|
||||
when(policyStore.get("a")).thenReturn(Optional.of(p));
|
||||
when(policyAccessGuard.canAccess(p)).thenReturn(true);
|
||||
when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP)))
|
||||
when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP), any()))
|
||||
.thenReturn(handle("run-9"));
|
||||
|
||||
ResponseEntity<JobResponse<Void>> response =
|
||||
@@ -716,6 +754,35 @@ class PolicyControllerTest {
|
||||
assertThat(response.getBody().getJobId()).isEqualTo("run-9");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("records the caller's own id for a single-document run")
|
||||
void carriesTheCallersDocumentReference() throws Exception {
|
||||
// The point of the field: a failure names a document the client that started it can
|
||||
// resolve.
|
||||
assertThat(documentReferenceOf(filesWith("editor-file-1", 1)))
|
||||
.isEqualTo("editor-file-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("records nothing when the run carries several documents")
|
||||
void refusesToGuessWhichOfSeveralDocumentsItIs() throws Exception {
|
||||
// One incident, one reference: naming one of several would attribute it to whichever
|
||||
// bound first.
|
||||
assertThat(documentReferenceOf(filesWith("editor-file-1", 3))).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("records nothing when the caller sent no id")
|
||||
void toleratesACallerThatSendsNoReference() throws Exception {
|
||||
assertThat(documentReferenceOf(filesWith(null, 1))).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("records nothing for a blank id")
|
||||
void treatsABlankReferenceAsNone() throws Exception {
|
||||
assertThat(documentReferenceOf(filesWith(" ", 1))).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("not found when the stored policy is inaccessible")
|
||||
void notFound() {
|
||||
@@ -838,7 +905,7 @@ class PolicyControllerTest {
|
||||
Policy p = policy("a", 1L);
|
||||
when(policyStore.get("a")).thenReturn(Optional.of(p));
|
||||
when(policyAccessGuard.canAccess(p)).thenReturn(true);
|
||||
when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP)))
|
||||
when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP), any()))
|
||||
.thenReturn(handle("run-9"));
|
||||
|
||||
ResponseEntity<JobResponse<Void>> response =
|
||||
|
||||
+49
-4
@@ -32,6 +32,7 @@ import org.springframework.core.io.ByteArrayResource;
|
||||
import stirling.software.proprietary.policy.input.InputSource;
|
||||
import stirling.software.proprietary.policy.input.ResolveContext;
|
||||
import stirling.software.proprietary.policy.input.ResolvedInput;
|
||||
import stirling.software.proprietary.policy.ledger.IdentityHasher;
|
||||
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
@@ -293,13 +294,56 @@ class PolicyRunnerTest {
|
||||
Policy policy = policy(List.of(InputSpec.folder("/in")));
|
||||
PolicyInputs inputs = PolicyInputs.of(List.of());
|
||||
PolicyRunHandle handle = new PolicyRunHandle("r", new CompletableFuture<>());
|
||||
when(policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP))
|
||||
when(policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP, null, null))
|
||||
.thenReturn(handle);
|
||||
|
||||
assertSame(handle, runner.runWith(policy, inputs, PolicyProgressListener.NOOP));
|
||||
assertSame(handle, runner.runWith(policy, inputs, PolicyProgressListener.NOOP, null));
|
||||
verifyNoInteractions(folderSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAttendedRunCarriesTheClientsOwnDocumentReferenceAndNoSource() {
|
||||
// A failure of this run can then name the document the user is still holding, and the null
|
||||
// sourceId is what marks the reference as the client's own rather than a source's hash.
|
||||
Policy policy = policy(List.of());
|
||||
PolicyInputs inputs = PolicyInputs.of(List.of(new ByteArrayResource("a".getBytes())));
|
||||
when(policyEngine.runPolicy(
|
||||
policy, inputs, PolicyProgressListener.NOOP, null, "editor-file-1"))
|
||||
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
|
||||
|
||||
runner.runWith(policy, inputs, PolicyProgressListener.NOOP, "editor-file-1");
|
||||
|
||||
verify(policyEngine)
|
||||
.runPolicy(policy, inputs, PolicyProgressListener.NOOP, null, "editor-file-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnattendedRunStillCarriesItsSourcesHashedIdentity() throws Exception {
|
||||
// The other id space, unchanged: a folder identity is a path, and a path is a filename, so
|
||||
// what reaches the run is the one-way hash and never the client-minted kind of reference.
|
||||
InputSpec spec = InputSpec.folder("/in");
|
||||
Policy policy = policy(List.of(spec));
|
||||
String sourceId = policy.inputs().getFirst().sourceId();
|
||||
when(folderSource.supports(spec)).thenReturn(true);
|
||||
when(folderSource.resolve(eq(spec), any()))
|
||||
.thenReturn(
|
||||
List.of(
|
||||
ResolvedInput.forFile(
|
||||
PolicyInputs.of(List.of()), "/in/doc.pdf", success -> {})));
|
||||
when(policyEngine.runPolicy(any(), any(), any(), any(), any()))
|
||||
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
|
||||
|
||||
runner.run(policy);
|
||||
|
||||
verify(policyEngine)
|
||||
.runPolicy(
|
||||
eq(policy),
|
||||
any(),
|
||||
any(),
|
||||
eq(sourceId),
|
||||
eq(IdentityHasher.identityHash("/in/doc.pdf")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void runWithRecordsSuppliedDocsAgainstTheEditorSourceForThePolicyTeam() {
|
||||
Policy policy =
|
||||
@@ -317,10 +361,11 @@ class PolicyRunnerTest {
|
||||
List.of(
|
||||
new ByteArrayResource("a".getBytes()),
|
||||
new ByteArrayResource("b".getBytes())));
|
||||
when(policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP))
|
||||
when(policyEngine.runPolicy(
|
||||
policy, inputs, PolicyProgressListener.NOOP, null, "editor-file-1"))
|
||||
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
|
||||
|
||||
runner.runWith(policy, inputs, PolicyProgressListener.NOOP);
|
||||
runner.runWith(policy, inputs, PolicyProgressListener.NOOP, "editor-file-1");
|
||||
|
||||
String key = EditorSource.counterKey(7L);
|
||||
assertEquals(2, docCounter.statsFor(List.of(key)).get(key).total());
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7
-85
@@ -4,14 +4,12 @@ import java.util.List;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
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;
|
||||
|
||||
@@ -19,25 +17,9 @@ import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.model.TeamMembership;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
import stirling.software.saas.accountlink.LeaderTeamResolver.LeaderTeam;
|
||||
|
||||
/**
|
||||
* Account-link registration surface (combined-billing "Mode A").
|
||||
*
|
||||
* <p>A self-hosted instance's local backend calls {@code POST /register} with the admin's
|
||||
* short-lived Supabase JWT (validated by the existing {@code SupabaseSecurityConfig} chain — no new
|
||||
* auth here). We resolve the caller's team, mint a device credential bound to it, and return the
|
||||
* secret exactly once. Ongoing entitlement reads authenticate with that device credential, not this
|
||||
* JWT.
|
||||
*
|
||||
* <p>Whole surface gated behind {@code stirling.billing.account-link.enabled}: off → beans absent →
|
||||
* 404. Leader-only, and the team is always derived from the caller (never the request body).
|
||||
*/
|
||||
/** Team-wide management of linked instances (combined billing). */
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@@ -47,25 +29,13 @@ import stirling.software.saas.util.AuthenticationUtils;
|
||||
public class AccountLinkController {
|
||||
|
||||
private final AccountLinkService service;
|
||||
private final TeamMembershipRepository memberRepo;
|
||||
private final UserRepository userRepository;
|
||||
private final LeaderTeamResolver leaderTeams;
|
||||
|
||||
public AccountLinkController(
|
||||
AccountLinkService service,
|
||||
TeamMembershipRepository memberRepo,
|
||||
UserRepository userRepository) {
|
||||
public AccountLinkController(AccountLinkService service, LeaderTeamResolver leaderTeams) {
|
||||
this.service = service;
|
||||
this.memberRepo = memberRepo;
|
||||
this.userRepository = userRepository;
|
||||
this.leaderTeams = leaderTeams;
|
||||
}
|
||||
|
||||
/** Optional display name for the instance (hostname / label). */
|
||||
public record RegisterRequest(String name) {}
|
||||
|
||||
/** {@code deviceSecret} is plaintext and returned exactly once — the caller must store it. */
|
||||
public record RegisterResponse(
|
||||
Long instanceId, Long teamId, String deviceId, String deviceSecret, String name) {}
|
||||
|
||||
public record InstanceRow(
|
||||
Long instanceId,
|
||||
String deviceId,
|
||||
@@ -74,31 +44,10 @@ public class AccountLinkController {
|
||||
String lastSeenAt,
|
||||
boolean revoked) {}
|
||||
|
||||
@PostMapping("/register")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<RegisterResponse> register(
|
||||
@RequestBody(required = false) RegisterRequest req, Authentication auth) {
|
||||
LeaderTeam lt = resolveLeaderTeam(auth);
|
||||
if (lt.error() != null) {
|
||||
return ResponseEntity.status(lt.error()).build();
|
||||
}
|
||||
String name = req != null ? req.name() : null;
|
||||
AccountLinkService.RegisteredInstance reg =
|
||||
service.register(lt.teamId(), lt.userId(), name);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(
|
||||
new RegisterResponse(
|
||||
reg.instanceId(),
|
||||
lt.teamId(),
|
||||
reg.deviceId(),
|
||||
reg.deviceSecret(),
|
||||
reg.name()));
|
||||
}
|
||||
|
||||
@GetMapping("/instances")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<List<InstanceRow>> list(Authentication auth) {
|
||||
LeaderTeam lt = resolveLeaderTeam(auth);
|
||||
LeaderTeam lt = leaderTeams.resolve(auth);
|
||||
if (lt.error() != null) {
|
||||
return ResponseEntity.status(lt.error()).build();
|
||||
}
|
||||
@@ -124,38 +73,11 @@ public class AccountLinkController {
|
||||
@PostMapping("/instances/{instanceId}/revoke")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<Void> revoke(@PathVariable Long instanceId, Authentication auth) {
|
||||
LeaderTeam lt = resolveLeaderTeam(auth);
|
||||
LeaderTeam lt = leaderTeams.resolve(auth);
|
||||
if (lt.error() != null) {
|
||||
return ResponseEntity.status(lt.error()).build();
|
||||
}
|
||||
boolean ok = service.revoke(lt.teamId(), instanceId);
|
||||
return ok ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Helpers — team always derived from the caller; instance linking is a leader (billing) action.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolved caller team, or an {@code error} status to return (teamId/userId null when error).
|
||||
*/
|
||||
private record LeaderTeam(Long teamId, Long userId, HttpStatus error) {}
|
||||
|
||||
private LeaderTeam resolveLeaderTeam(Authentication auth) {
|
||||
User user;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (SecurityException e) {
|
||||
return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
|
||||
if (rows.isEmpty()) {
|
||||
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
TeamMembership m = rows.getFirst();
|
||||
if (m.getRole() != TeamRole.LEADER) {
|
||||
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return new LeaderTeam(m.getTeam().getId(), user.getId(), null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,16 +18,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Account-link instance registration + lifecycle (combined-billing "Mode A").
|
||||
*
|
||||
* <p>Mints a {@code device_id} (public) + {@code device_secret} (high-entropy, returned once) bound
|
||||
* to a team, persisting only the SHA-256 hash of the secret. The instance authenticates its
|
||||
* unattended entitlement reads with that credential.
|
||||
*
|
||||
* <p>Gated behind {@code stirling.billing.account-link.enabled}: when off the bean is absent, so
|
||||
* {@link AccountLinkController} (which depends on it) drops out too and its endpoints 404.
|
||||
*/
|
||||
/** Account-link instance registration + lifecycle (combined billing). */
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@@ -80,10 +71,7 @@ public class AccountLinkService {
|
||||
return repo.findByTeamIdOrderByCreatedAtDesc(teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes an instance iff it belongs to {@code teamId}. Returns false if not found or owned by
|
||||
* a different team (so a caller can never revoke another team's instance). Idempotent.
|
||||
*/
|
||||
/** Revokes an instance iff it belongs to {@code teamId}. */
|
||||
@Transactional
|
||||
public boolean revoke(Long teamId, Long instanceId) {
|
||||
Optional<LinkedInstance> found = repo.findById(instanceId);
|
||||
@@ -99,13 +87,30 @@ public class AccountLinkService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an active instance from a device credential, or empty if it does not authenticate.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<LinkedInstance> resolveActiveInstance(String deviceId, String deviceSecret) {
|
||||
if (deviceId == null || deviceSecret == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return repo.findByDeviceIdAndRevokedAtIsNull(deviceId)
|
||||
.filter(
|
||||
instance ->
|
||||
MessageDigest.isEqual(
|
||||
sha256Hex(deviceSecret).getBytes(StandardCharsets.UTF_8),
|
||||
instance.getDeviceSecretHash()
|
||||
.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
private String randomSecret() {
|
||||
byte[] buf = new byte[SECRET_BYTES];
|
||||
random.nextBytes(buf);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
|
||||
}
|
||||
|
||||
/** SHA-256 hex of a value. The device secret is high-entropy, so no salt is required. */
|
||||
/** SHA-256 hex of a value. */
|
||||
static String sha256Hex(String value) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
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 io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.saas.accountlink.LeaderTeamResolver.LeaderTeam;
|
||||
|
||||
/** Browser-mediated "connect this server" handshake. */
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/account-link/connect")
|
||||
@Profile("saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class ConnectController {
|
||||
|
||||
/** Same headers the device-credential filter uses on the {@code /api/v1/instance} paths. */
|
||||
static final String HEADER_DEVICE_ID = "X-Device-Id";
|
||||
|
||||
static final String HEADER_DEVICE_SECRET = "X-Device-Secret";
|
||||
|
||||
/** Frontend route serving the approval page. */
|
||||
static final String LINK_PATH = "/link";
|
||||
|
||||
private final ConnectRequestService service;
|
||||
private final LeaderTeamResolver leaderTeams;
|
||||
private final AccountLinkService accountLinkService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public ConnectController(
|
||||
ConnectRequestService service,
|
||||
LeaderTeamResolver leaderTeams,
|
||||
AccountLinkService accountLinkService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.service = service;
|
||||
this.leaderTeams = leaderTeams;
|
||||
this.accountLinkService = accountLinkService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
/** Sent by the instance's own backend, before it holds any credential. */
|
||||
public record CreateBody(String name, String callbackUrl, String nonce, String claimSecret) {}
|
||||
|
||||
/** {@code authorizeUrl} is where the instance should send its admin. */
|
||||
public record CreateResponse(String requestId, int expiresIn, String authorizeUrl) {}
|
||||
|
||||
/** What the approval page renders. */
|
||||
public record ViewResponse(
|
||||
String requestId,
|
||||
String name,
|
||||
String callbackOrigin,
|
||||
boolean insecureTransport,
|
||||
String mode,
|
||||
String status) {}
|
||||
|
||||
/** Where the approver's browser goes next, and the correlator the instance is waiting on. */
|
||||
public record ApproveResponse(String callbackUrl, String nonce) {}
|
||||
|
||||
public record ClaimBody(String requestId, String claimSecret) {}
|
||||
|
||||
public record ClaimResponse(String deviceId, String deviceSecret, Long teamId) {}
|
||||
|
||||
/** Opens a handshake. */
|
||||
@PostMapping("/request")
|
||||
public ResponseEntity<?> request(
|
||||
@RequestBody(required = false) CreateBody body, HttpServletRequest http) {
|
||||
if (body == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "BAD_REQUEST"));
|
||||
}
|
||||
String deviceId = http.getHeader(HEADER_DEVICE_ID);
|
||||
String deviceSecret = http.getHeader(HEADER_DEVICE_SECRET);
|
||||
boolean reauthRequested = deviceId != null || deviceSecret != null;
|
||||
|
||||
ConnectRequestService.CreateResult result;
|
||||
if (reauthRequested) {
|
||||
Long pinnedTeamId =
|
||||
accountLinkService
|
||||
.resolveActiveInstance(deviceId, deviceSecret)
|
||||
.map(LinkedInstance::getTeamId)
|
||||
.orElse(null);
|
||||
result =
|
||||
service.createReauth(
|
||||
body.name(),
|
||||
body.callbackUrl(),
|
||||
body.nonce(),
|
||||
body.claimSecret(),
|
||||
clientIp(http),
|
||||
pinnedTeamId);
|
||||
} else {
|
||||
result =
|
||||
service.create(
|
||||
body.name(),
|
||||
body.callbackUrl(),
|
||||
body.nonce(),
|
||||
body.claimSecret(),
|
||||
clientIp(http));
|
||||
}
|
||||
if (result.isRejected()) {
|
||||
return switch (result.rejection()) {
|
||||
case RATE_LIMITED ->
|
||||
ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(Map.of("error", "RATE_LIMITED"));
|
||||
case BAD_CALLBACK ->
|
||||
ResponseEntity.badRequest().body(Map.of("error", "BAD_CALLBACK"));
|
||||
case BAD_NONCE -> ResponseEntity.badRequest().body(Map.of("error", "BAD_NONCE"));
|
||||
case BAD_SECRET -> ResponseEntity.badRequest().body(Map.of("error", "BAD_SECRET"));
|
||||
// A credential was offered and did not authenticate. Same answer as any other bad
|
||||
// credential, and deliberately not distinguishable from "revoked".
|
||||
case NOT_LINKED ->
|
||||
ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "NOT_LINKED"));
|
||||
};
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(
|
||||
new CreateResponse(
|
||||
result.requestId(),
|
||||
result.expiresInSeconds(),
|
||||
authorizeUrl(result.requestId(), http)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to send the admin to approve a handshake. {@code system.frontendUrl} is the web app's
|
||||
* own base URL, including any base path; without it the API's origin has to serve the app too.
|
||||
*/
|
||||
private String authorizeUrl(String requestId, HttpServletRequest http) {
|
||||
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
String base =
|
||||
frontendUrl != null && !frontendUrl.isBlank()
|
||||
? frontendUrl.strip().replaceAll("/+$", "")
|
||||
: requestOrigin(http);
|
||||
return base
|
||||
+ LINK_PATH
|
||||
+ "?request="
|
||||
+ URLEncoder.encode(requestId, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** Scheme, host and context path as the browser reached us, honouring a reverse proxy. */
|
||||
private static String requestOrigin(HttpServletRequest request) {
|
||||
String proto = firstHop(request.getHeader("X-Forwarded-Proto"));
|
||||
String host = firstHop(request.getHeader("X-Forwarded-Host"));
|
||||
String scheme = proto != null ? proto : request.getScheme();
|
||||
// A forwarded host already carries its own port, if it needs one.
|
||||
String hostPort =
|
||||
host != null
|
||||
? host
|
||||
: Origins.hostPort(
|
||||
scheme, request.getServerName(), request.getServerPort());
|
||||
String context = request.getContextPath() == null ? "" : request.getContextPath();
|
||||
return scheme + "://" + hostPort + context;
|
||||
}
|
||||
|
||||
private static String firstHop(String headerValue) {
|
||||
if (headerValue == null || headerValue.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String first = headerValue.split(",")[0].strip();
|
||||
return first.isEmpty() ? null : first;
|
||||
}
|
||||
|
||||
/** Detail for the approval page. */
|
||||
@GetMapping("/{requestId}")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<ViewResponse> view(@PathVariable String requestId) {
|
||||
return service.lookup(requestId)
|
||||
.map(
|
||||
v ->
|
||||
ResponseEntity.ok(
|
||||
new ViewResponse(
|
||||
v.requestId(),
|
||||
v.name(),
|
||||
v.callbackOrigin(),
|
||||
v.insecureTransport(),
|
||||
v.mode().name(),
|
||||
v.status().name())))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/** Approves a handshake. */
|
||||
@PostMapping("/{requestId}/approve")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<?> approve(@PathVariable String requestId, Authentication auth) {
|
||||
Optional<ConnectRequestService.ConnectView> view = service.lookup(requestId);
|
||||
if (view.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
boolean reauth = view.get().mode() == ConnectRequest.Mode.REAUTH;
|
||||
LeaderTeam lt = reauth ? leaderTeams.resolveMember(auth) : leaderTeams.resolve(auth);
|
||||
if (lt.isError()) {
|
||||
return ResponseEntity.status(lt.error()).build();
|
||||
}
|
||||
ConnectRequestService.ApproveResult result =
|
||||
service.approve(requestId, lt.teamId(), lt.userId());
|
||||
if (result.isRejected()) {
|
||||
return switch (result.rejection()) {
|
||||
// Named separately so the page can say "you are signed in to a different account"
|
||||
// rather than implying the request itself was bad.
|
||||
case WRONG_TEAM ->
|
||||
ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "WRONG_TEAM"));
|
||||
case UNAVAILABLE -> ResponseEntity.notFound().build();
|
||||
};
|
||||
}
|
||||
return ResponseEntity.ok(
|
||||
new ApproveResponse(result.target().callbackUrl(), result.target().nonce()));
|
||||
}
|
||||
|
||||
@PostMapping("/{requestId}/deny")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<Void> deny(@PathVariable String requestId, Authentication auth) {
|
||||
LeaderTeam lt = leaderTeams.resolve(auth);
|
||||
if (lt.isError()) {
|
||||
return ResponseEntity.status(lt.error()).build();
|
||||
}
|
||||
return service.deny(requestId)
|
||||
? ResponseEntity.noContent().build()
|
||||
: ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
/** Collects the device credential. */
|
||||
@PostMapping("/claim")
|
||||
public ResponseEntity<?> claim(@RequestBody(required = false) ClaimBody body) {
|
||||
if (body == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "BAD_REQUEST"));
|
||||
}
|
||||
ConnectRequestService.ClaimResult result =
|
||||
service.claim(body.requestId(), body.claimSecret());
|
||||
return switch (result.outcome()) {
|
||||
case GRANTED ->
|
||||
ResponseEntity.ok(
|
||||
new ClaimResponse(
|
||||
result.deviceId(), result.deviceSecret(), result.teamId()));
|
||||
// A re-authentication carries no credential: the instance already has one. It only
|
||||
// needs to know the browser leg succeeded, and which team it was confirmed against.
|
||||
case CONFIRMED ->
|
||||
ResponseEntity.ok(Map.of("status", "confirmed", "teamId", result.teamId()));
|
||||
case PENDING ->
|
||||
ResponseEntity.status(HttpStatus.ACCEPTED).body(Map.of("status", "pending"));
|
||||
case REJECTED -> ResponseEntity.badRequest().body(Map.of("error", "CONNECT_REJECTED"));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Source address for the creation cap.
|
||||
*
|
||||
* <p>Deliberately not reading {@code X-Forwarded-For}: the caller sets it, so keying a cap on
|
||||
* it lets one rotate fake addresses and have no cap at all. {@code
|
||||
* server.forward-headers-strategy} is NATIVE, so the container has already resolved the real
|
||||
* client from trusted proxies.
|
||||
*/
|
||||
private static String clientIp(HttpServletRequest request) {
|
||||
String remote = request.getRemoteAddr();
|
||||
return remote == null || remote.length() <= 45 ? remote : remote.substring(0, 45);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/** One in-flight "connect this server" handshake. Short lived and single use. */
|
||||
@Entity
|
||||
@Table(
|
||||
name = "account_link_connect_request",
|
||||
indexes = @Index(name = "idx_alcr_ip_created", columnList = "requester_ip,created_at"))
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class ConnectRequest {
|
||||
|
||||
public enum Mode {
|
||||
LINK,
|
||||
REAUTH
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
PENDING,
|
||||
APPROVED,
|
||||
DENIED,
|
||||
CONSUMED
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "request_id", nullable = false, unique = true, length = 64)
|
||||
private String requestId;
|
||||
|
||||
@Column(name = "name", length = 255)
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* Read back from here on approval, never from the request: that is what stops an open redirect.
|
||||
*/
|
||||
@Column(name = "callback_url", nullable = false, length = 2048)
|
||||
private String callbackUrl;
|
||||
|
||||
@Column(name = "callback_origin", nullable = false, length = 255)
|
||||
private String callbackOrigin;
|
||||
|
||||
@Column(name = "nonce", nullable = false, length = 128)
|
||||
private String nonce;
|
||||
|
||||
/** SHA-256; the secret itself is never stored. */
|
||||
@Column(name = "claim_secret_hash", nullable = false, length = 64)
|
||||
private String claimSecretHash;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "mode", nullable = false, length = 16)
|
||||
private Mode mode = Mode.LINK;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 16)
|
||||
private Status status = Status.PENDING;
|
||||
|
||||
/** LINK: set on approval. REAUTH: pinned at creation, so approval can only confirm it. */
|
||||
@Column(name = "team_id")
|
||||
private Long teamId;
|
||||
|
||||
@Column(name = "approved_by_user_id")
|
||||
private Long approvedByUserId;
|
||||
|
||||
@Column(name = "requester_ip", length = 45)
|
||||
private String requesterIp;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
@Column(name = "approved_at")
|
||||
private LocalDateTime approvedAt;
|
||||
|
||||
@Column(name = "consumed_at")
|
||||
private LocalDateTime consumedAt;
|
||||
|
||||
public boolean isExpired(LocalDateTime now) {
|
||||
return expiresAt != null && expiresAt.isBefore(now);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Removes connect requests that are past use.
|
||||
*
|
||||
* <p>Needed rather than merely tidy: anyone can create a row on {@code POST /connect/request}, and
|
||||
* nothing else deletes one. Requests hold a callback URL and the requester's address, so they are
|
||||
* swept soon after expiry rather than kept.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
@RequiredArgsConstructor
|
||||
public class ConnectRequestCleanupService {
|
||||
|
||||
/** Long enough to answer "what happened to my link?" the next morning, and no longer. */
|
||||
private static final int RETAIN_HOURS = 24;
|
||||
|
||||
private final ConnectRequestRepository repo;
|
||||
|
||||
@Scheduled(cron = "0 30 3 * * *")
|
||||
@Transactional
|
||||
public void purgeExpired() {
|
||||
try {
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusHours(RETAIN_HOURS);
|
||||
int deleted = repo.deleteByExpiresAtBefore(cutoff);
|
||||
if (deleted > 0) {
|
||||
log.info("Account-link connect: purged {} expired requests", deleted);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// A failed sweep must not take the scheduler down; the next run retries.
|
||||
log.error("Account-link connect: purge failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
|
||||
/** Data access for {@link ConnectRequest}. */
|
||||
public interface ConnectRequestRepository extends JpaRepository<ConnectRequest, Long> {
|
||||
|
||||
Optional<ConnectRequest> findByRequestId(String requestId);
|
||||
|
||||
/** Row-locking read used by approve, deny and claim. */
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT r FROM ConnectRequest r WHERE r.requestId = :requestId")
|
||||
Optional<ConnectRequest> findByRequestIdForUpdate(@Param("requestId") String requestId);
|
||||
|
||||
/** Backs the per-IP creation cap, since creating a request needs no authentication. */
|
||||
long countByRequesterIpAndCreatedAtAfter(String requesterIp, LocalDateTime after);
|
||||
|
||||
/** Sweeps rows past use, whatever they settled as. Anyone can create these. */
|
||||
int deleteByExpiresAtBefore(LocalDateTime cutoff);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/** The "connect this server" handshake, SaaS side. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class ConnectRequestService {
|
||||
|
||||
/**
|
||||
* Long enough for the approver to sign in, pick the right account and read the origin. Sized
|
||||
* for the slowest real route: signing up, waiting for a confirmation email, and coming back.
|
||||
*/
|
||||
static final int LIFETIME_MINUTES = 30;
|
||||
|
||||
/** Creating a request needs no authentication, so the only brake is per-source volume. */
|
||||
static final int MAX_REQUESTS_PER_IP = 10;
|
||||
|
||||
private static final int REQUEST_ID_BYTES = 32;
|
||||
private static final int MAX_NONCE_LENGTH = 128;
|
||||
private static final int MAX_CALLBACK_LENGTH = 2048;
|
||||
private static final int MAX_NAME_LENGTH = 255;
|
||||
|
||||
private final ConnectRequestRepository repo;
|
||||
private final AccountLinkService accountLinkService;
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
public ConnectRequestService(
|
||||
ConnectRequestRepository repo, AccountLinkService accountLinkService) {
|
||||
this.repo = repo;
|
||||
this.accountLinkService = accountLinkService;
|
||||
}
|
||||
|
||||
/** Rejected creation attempts, so the controller can pick a status without parsing messages. */
|
||||
public enum CreateRejection {
|
||||
BAD_CALLBACK,
|
||||
BAD_NONCE,
|
||||
BAD_SECRET,
|
||||
RATE_LIMITED,
|
||||
/**
|
||||
* A re-authentication was asked for by something that could not prove it is a linked
|
||||
* instance.
|
||||
*/
|
||||
NOT_LINKED
|
||||
}
|
||||
|
||||
/** Either a created request id, or the reason we would not create one. */
|
||||
public record CreateResult(String requestId, int expiresInSeconds, CreateRejection rejection) {
|
||||
static CreateResult ok(String requestId, int expiresInSeconds) {
|
||||
return new CreateResult(requestId, expiresInSeconds, null);
|
||||
}
|
||||
|
||||
static CreateResult rejected(CreateRejection rejection) {
|
||||
return new CreateResult(null, 0, rejection);
|
||||
}
|
||||
|
||||
public boolean isRejected() {
|
||||
return rejection != null;
|
||||
}
|
||||
}
|
||||
|
||||
/** What the approval page shows. */
|
||||
public record ConnectView(
|
||||
String requestId,
|
||||
String name,
|
||||
String callbackOrigin,
|
||||
boolean insecureTransport,
|
||||
ConnectRequest.Mode mode,
|
||||
ConnectRequest.Status status) {}
|
||||
|
||||
/** Where to send the browser once approved, plus the correlator the instance is expecting. */
|
||||
public record ApprovalTarget(String callbackUrl, String nonce) {}
|
||||
|
||||
public enum ClaimOutcome {
|
||||
/** Approved and collected; {@code credential} is populated. */
|
||||
GRANTED,
|
||||
/** A re-authentication was approved. */
|
||||
CONFIRMED,
|
||||
/** Still waiting on a human. */
|
||||
PENDING,
|
||||
/** Declined, expired, unknown, already collected, or a bad claim secret. */
|
||||
REJECTED
|
||||
}
|
||||
|
||||
public record ClaimResult(
|
||||
ClaimOutcome outcome, String deviceId, String deviceSecret, Long teamId) {
|
||||
static ClaimResult of(ClaimOutcome outcome) {
|
||||
return new ClaimResult(outcome, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Records a handshake on behalf of an instance that has no credential yet. */
|
||||
@Transactional
|
||||
public CreateResult create(
|
||||
String name, String callbackUrl, String nonce, String claimSecret, String requesterIp) {
|
||||
return create(name, callbackUrl, nonce, claimSecret, requesterIp, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* As {@link #create}, but for an instance that is already linked and only needs its admin's
|
||||
* browser signed in again.
|
||||
*/
|
||||
@Transactional
|
||||
public CreateResult createReauth(
|
||||
String name,
|
||||
String callbackUrl,
|
||||
String nonce,
|
||||
String claimSecret,
|
||||
String requesterIp,
|
||||
Long pinnedTeamId) {
|
||||
if (pinnedTeamId == null) {
|
||||
return CreateResult.rejected(CreateRejection.NOT_LINKED);
|
||||
}
|
||||
return create(name, callbackUrl, nonce, claimSecret, requesterIp, pinnedTeamId);
|
||||
}
|
||||
|
||||
private CreateResult create(
|
||||
String name,
|
||||
String callbackUrl,
|
||||
String nonce,
|
||||
String claimSecret,
|
||||
String requesterIp,
|
||||
Long pinnedTeamId) {
|
||||
if (nonce == null || nonce.isBlank() || nonce.length() > MAX_NONCE_LENGTH) {
|
||||
return CreateResult.rejected(CreateRejection.BAD_NONCE);
|
||||
}
|
||||
if (claimSecret == null || claimSecret.isBlank()) {
|
||||
return CreateResult.rejected(CreateRejection.BAD_SECRET);
|
||||
}
|
||||
Optional<URI> parsed = validateCallback(callbackUrl);
|
||||
if (parsed.isEmpty()) {
|
||||
return CreateResult.rejected(CreateRejection.BAD_CALLBACK);
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (requesterIp != null
|
||||
&& repo.countByRequesterIpAndCreatedAtAfter(requesterIp, now.minusHours(1))
|
||||
>= MAX_REQUESTS_PER_IP) {
|
||||
return CreateResult.rejected(CreateRejection.RATE_LIMITED);
|
||||
}
|
||||
|
||||
URI uri = parsed.get();
|
||||
ConnectRequest request = new ConnectRequest();
|
||||
request.setRequestId(randomToken());
|
||||
request.setName(trim(name, MAX_NAME_LENGTH));
|
||||
request.setCallbackUrl(uri.toString());
|
||||
request.setCallbackOrigin(originOf(uri));
|
||||
request.setNonce(nonce);
|
||||
request.setClaimSecretHash(sha256Hex(claimSecret));
|
||||
request.setStatus(ConnectRequest.Status.PENDING);
|
||||
request.setMode(
|
||||
pinnedTeamId == null ? ConnectRequest.Mode.LINK : ConnectRequest.Mode.REAUTH);
|
||||
request.setTeamId(pinnedTeamId);
|
||||
request.setRequesterIp(requesterIp);
|
||||
request.setExpiresAt(now.plusMinutes(LIFETIME_MINUTES));
|
||||
repo.save(request);
|
||||
|
||||
// Never log the nonce or the claim secret; both are live. The request id is the safe
|
||||
// handle for correlating a support request against this row.
|
||||
log.info(
|
||||
"Account-link connect: request {} created for origin {}",
|
||||
request.getRequestId(),
|
||||
request.getCallbackOrigin());
|
||||
return CreateResult.ok(request.getRequestId(), LIFETIME_MINUTES * 60);
|
||||
}
|
||||
|
||||
/** The approver's view of a handshake. */
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<ConnectView> lookup(String requestId) {
|
||||
return repo.findByRequestId(requestId)
|
||||
.filter(r -> !r.isExpired(LocalDateTime.now()))
|
||||
.map(
|
||||
r ->
|
||||
new ConnectView(
|
||||
r.getRequestId(),
|
||||
r.getName(),
|
||||
r.getCallbackOrigin(),
|
||||
!"https".equals(schemeOf(r.getCallbackOrigin())),
|
||||
r.getMode(),
|
||||
r.getStatus()));
|
||||
}
|
||||
|
||||
/** Why an approval was refused, so the page can say something useful. */
|
||||
public enum ApproveRejection {
|
||||
/** Unknown, expired, or already settled. */
|
||||
UNAVAILABLE,
|
||||
/** The approver's team is not the team this server already belongs to. */
|
||||
WRONG_TEAM
|
||||
}
|
||||
|
||||
public record ApproveResult(ApprovalTarget target, ApproveRejection rejection) {
|
||||
public boolean isRejected() {
|
||||
return target == null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Binds a pending handshake to the approver's team and returns where to send them next. */
|
||||
@Transactional
|
||||
public ApproveResult approve(String requestId, Long teamId, Long userId) {
|
||||
Optional<ConnectRequest> found = repo.findByRequestIdForUpdate(requestId);
|
||||
if (found.isEmpty()) {
|
||||
return new ApproveResult(null, ApproveRejection.UNAVAILABLE);
|
||||
}
|
||||
ConnectRequest request = found.get();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (request.isExpired(now) || request.getStatus() != ConnectRequest.Status.PENDING) {
|
||||
return new ApproveResult(null, ApproveRejection.UNAVAILABLE);
|
||||
}
|
||||
Long pinned = request.getTeamId();
|
||||
if (pinned != null && !pinned.equals(teamId)) {
|
||||
log.warn(
|
||||
"Account-link connect: request {} approved by team {} but is pinned to team {};"
|
||||
+ " refusing",
|
||||
requestId,
|
||||
teamId,
|
||||
pinned);
|
||||
return new ApproveResult(null, ApproveRejection.WRONG_TEAM);
|
||||
}
|
||||
request.setStatus(ConnectRequest.Status.APPROVED);
|
||||
request.setTeamId(teamId);
|
||||
request.setApprovedByUserId(userId);
|
||||
request.setApprovedAt(now);
|
||||
repo.save(request);
|
||||
log.info(
|
||||
"Account-link connect: request {} approved for team {} ({})",
|
||||
requestId,
|
||||
teamId,
|
||||
request.getMode());
|
||||
return new ApproveResult(
|
||||
new ApprovalTarget(request.getCallbackUrl(), request.getNonce()), null);
|
||||
}
|
||||
|
||||
/** Declines a pending handshake. */
|
||||
@Transactional
|
||||
public boolean deny(String requestId) {
|
||||
Optional<ConnectRequest> found = repo.findByRequestIdForUpdate(requestId);
|
||||
if (found.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
ConnectRequest request = found.get();
|
||||
if (request.getStatus() != ConnectRequest.Status.PENDING) {
|
||||
return false;
|
||||
}
|
||||
request.setStatus(ConnectRequest.Status.DENIED);
|
||||
repo.save(request);
|
||||
log.info("Account-link connect: request {} denied", requestId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Collects the device credential for an approved handshake. */
|
||||
@Transactional
|
||||
public ClaimResult claim(String requestId, String claimSecret) {
|
||||
if (requestId == null || claimSecret == null) {
|
||||
return ClaimResult.of(ClaimOutcome.REJECTED);
|
||||
}
|
||||
Optional<ConnectRequest> found = repo.findByRequestIdForUpdate(requestId);
|
||||
if (found.isEmpty()) {
|
||||
return ClaimResult.of(ClaimOutcome.REJECTED);
|
||||
}
|
||||
ConnectRequest request = found.get();
|
||||
if (!secretMatches(claimSecret, request.getClaimSecretHash())) {
|
||||
// Same answer as an unknown id: a caller probing ids learns nothing from the
|
||||
// difference.
|
||||
log.warn("Account-link connect: claim for request {} had a bad secret", requestId);
|
||||
return ClaimResult.of(ClaimOutcome.REJECTED);
|
||||
}
|
||||
if (request.isExpired(LocalDateTime.now())) {
|
||||
return ClaimResult.of(ClaimOutcome.REJECTED);
|
||||
}
|
||||
return switch (request.getStatus()) {
|
||||
case PENDING -> ClaimResult.of(ClaimOutcome.PENDING);
|
||||
case APPROVED -> mint(request);
|
||||
case DENIED, CONSUMED -> ClaimResult.of(ClaimOutcome.REJECTED);
|
||||
};
|
||||
}
|
||||
|
||||
/** Settles an approved handshake. */
|
||||
private ClaimResult mint(ConnectRequest request) {
|
||||
if (request.getMode() == ConnectRequest.Mode.REAUTH) {
|
||||
request.setStatus(ConnectRequest.Status.CONSUMED);
|
||||
request.setConsumedAt(LocalDateTime.now());
|
||||
repo.save(request);
|
||||
log.info(
|
||||
"Account-link connect: request {} re-authenticated for team {}",
|
||||
request.getRequestId(),
|
||||
request.getTeamId());
|
||||
return new ClaimResult(ClaimOutcome.CONFIRMED, null, null, request.getTeamId());
|
||||
}
|
||||
AccountLinkService.RegisteredInstance registered =
|
||||
accountLinkService.register(
|
||||
request.getTeamId(), request.getApprovedByUserId(), request.getName());
|
||||
request.setStatus(ConnectRequest.Status.CONSUMED);
|
||||
request.setConsumedAt(LocalDateTime.now());
|
||||
repo.save(request);
|
||||
log.info(
|
||||
"Account-link connect: request {} claimed, instance {} bound to team {}",
|
||||
request.getRequestId(),
|
||||
registered.instanceId(),
|
||||
request.getTeamId());
|
||||
return new ClaimResult(
|
||||
ClaimOutcome.GRANTED,
|
||||
registered.deviceId(),
|
||||
registered.deviceSecret(),
|
||||
request.getTeamId());
|
||||
}
|
||||
|
||||
/** Absolute http(s) URL, with a host, no credentials and no fragment of its own. */
|
||||
static Optional<URI> validateCallback(String candidate) {
|
||||
if (candidate == null || candidate.isBlank() || candidate.length() > MAX_CALLBACK_LENGTH) {
|
||||
return Optional.empty();
|
||||
}
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(candidate.strip());
|
||||
} catch (URISyntaxException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (!uri.isAbsolute() || uri.getScheme() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
|
||||
if (!"http".equals(scheme) && !"https".equals(scheme)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (uri.getHost() == null || uri.getHost().isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (uri.getUserInfo() != null || uri.getFragment() != null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(uri);
|
||||
}
|
||||
|
||||
/** Scheme, host and port, with the default port omitted so origins compare cleanly. */
|
||||
static String originOf(URI uri) {
|
||||
String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
|
||||
return scheme + "://" + Origins.hostPort(scheme, uri.getHost(), uri.getPort());
|
||||
}
|
||||
|
||||
private static String schemeOf(String origin) {
|
||||
int sep = origin.indexOf("://");
|
||||
return sep < 0 ? "" : origin.substring(0, sep);
|
||||
}
|
||||
|
||||
private static String trim(String value, int max) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String stripped = value.strip();
|
||||
if (stripped.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return stripped.length() <= max ? stripped : stripped.substring(0, max);
|
||||
}
|
||||
|
||||
private String randomToken() {
|
||||
byte[] buf = new byte[REQUEST_ID_BYTES];
|
||||
random.nextBytes(buf);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
|
||||
}
|
||||
|
||||
/** Constant-time comparison so a claim cannot be brute-forced a byte at a time. */
|
||||
private static boolean secretMatches(String candidate, String expectedHash) {
|
||||
if (expectedHash == null) {
|
||||
return false;
|
||||
}
|
||||
return MessageDigest.isEqual(
|
||||
sha256Hex(candidate).getBytes(StandardCharsets.UTF_8),
|
||||
expectedHash.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static String sha256Hex(String value) {
|
||||
return AccountLinkService.sha256Hex(value);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -19,7 +19,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Authenticates a linked self-hosted instance by its device credential (combined-billing "Mode A").
|
||||
* Authenticates a linked self-hosted instance by its device credential (combined billing).
|
||||
*
|
||||
* <p>Reads {@code X-Device-Id} + {@code X-Device-Secret}, looks up the active {@link
|
||||
* LinkedInstance}, and constant-time compares the SHA-256 of the presented secret against the
|
||||
|
||||
@@ -32,9 +32,9 @@ import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
import stirling.software.saas.payg.policy.PricingPolicyService;
|
||||
|
||||
/**
|
||||
* Instance-facing surface (combined-billing "Mode A"), authenticated by the <b>device
|
||||
* credential</b> — not a user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device
|
||||
* credential is scoped here and nowhere else.
|
||||
* Instance-facing surface (combined billing), authenticated by the <b>device credential</b> — not a
|
||||
* user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device credential is scoped
|
||||
* here and nowhere else.
|
||||
*
|
||||
* <p>{@code GET /whoami} is the MVP round-trip proof: a registered instance presenting a valid
|
||||
* device credential gets back its resolved {@code instanceId} + {@code teamId}. {@code GET
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.model.TeamMembership;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
|
||||
/** Who is allowed to bind a self-hosted instance to a team. */
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class LeaderTeamResolver {
|
||||
|
||||
private final TeamMembershipRepository memberRepo;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public LeaderTeamResolver(TeamMembershipRepository memberRepo, UserRepository userRepository) {
|
||||
this.memberRepo = memberRepo;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved caller, or an {@code error} status to return ({@code teamId}/{@code userId} null).
|
||||
*/
|
||||
public record LeaderTeam(Long teamId, Long userId, HttpStatus error) {
|
||||
public boolean isError() {
|
||||
return error != null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller must lead their team. */
|
||||
public LeaderTeam resolve(Authentication auth) {
|
||||
return resolve(auth, true);
|
||||
}
|
||||
|
||||
/** Caller need only belong to a team. */
|
||||
public LeaderTeam resolveMember(Authentication auth) {
|
||||
return resolve(auth, false);
|
||||
}
|
||||
|
||||
private LeaderTeam resolve(Authentication auth, boolean requireLeader) {
|
||||
User user;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (SecurityException e) {
|
||||
return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
|
||||
if (rows.isEmpty()) {
|
||||
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
TeamMembership membership = rows.getFirst();
|
||||
if (requireLeader && membership.getRole() != TeamRole.LEADER) {
|
||||
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return new LeaderTeam(membership.getTeam().getId(), user.getId(), null);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* One self-hosted instance that has linked a SaaS account (combined-billing "Mode A", {@code
|
||||
* One self-hosted instance that has linked a SaaS account (combined billing, {@code
|
||||
* linked_instance}, V22).
|
||||
*
|
||||
* <p>Created by {@code POST /api/v1/account-link/register}, authenticated with the admin's
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
||||
/**
|
||||
* Authentication for a linked self-hosted instance (combined-billing "Mode A").
|
||||
* Authentication for a linked self-hosted instance (combined billing).
|
||||
*
|
||||
* <p>Deliberately <em>not</em> a user: the principal is the instance ({@code instanceId}) bound to
|
||||
* a {@code teamId}, with the single authority {@code ROLE_LINKED_INSTANCE}. It carries no {@code
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
/**
|
||||
* Origin formatting shared by the connect handshake.
|
||||
*
|
||||
* <p>One place on purpose: the origin a request arrives on and the origin parsed out of a callback
|
||||
* URL are compared with each other, so if either side stopped omitting the default port the
|
||||
* comparison would start failing quietly.
|
||||
*/
|
||||
final class Origins {
|
||||
|
||||
private Origins() {}
|
||||
|
||||
/** {@code host} or {@code host:port}, dropping a port that is the scheme's default. */
|
||||
static String hostPort(String scheme, String host, int port) {
|
||||
boolean isDefault =
|
||||
port <= 0
|
||||
|| ("http".equals(scheme) && port == 80)
|
||||
|| ("https".equals(scheme) && port == 443);
|
||||
return isDefault ? host : host + ":" + port;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ public final class SaasSchemaOwnership {
|
||||
*/
|
||||
public static final Set<String> MIGRATION_OWNED =
|
||||
Set.of(
|
||||
"account_link_connect_request",
|
||||
"ai_create_sessions",
|
||||
"audit_events",
|
||||
"authorities",
|
||||
@@ -78,6 +79,7 @@ public final class SaasSchemaOwnership {
|
||||
*/
|
||||
public static final Set<String> HIBERNATE_MANAGED =
|
||||
Set.of(
|
||||
"account_link_connect_state",
|
||||
"account_link_device_credential",
|
||||
"account_link_metered_signature",
|
||||
"account_link_sync_state",
|
||||
|
||||
+6
-6
@@ -18,12 +18,12 @@ import stirling.software.saas.payg.model.ProcessType;
|
||||
import stirling.software.saas.payg.repository.PaygInstanceUsageRepository;
|
||||
|
||||
/**
|
||||
* Ingests a linked instance's daily usage sync (combined-billing "Mode A"). The instance reports a
|
||||
* monotonic cumulative unit total per {@link BillingCategory}; we bill only the delta since the
|
||||
* last sync via {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split,
|
||||
* ledger DEBIT, Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and
|
||||
* tamper-evident (a backwards total is refused; a monotonic {@code syncSeq} dedups replays). The
|
||||
* cap is enforced at the instance gate, not here. Gated behind {@code account-link.enabled}.
|
||||
* Ingests a linked instance's daily usage sync (combined billing). The instance reports a monotonic
|
||||
* cumulative unit total per {@link BillingCategory}; we bill only the delta since the last sync via
|
||||
* {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split, ledger DEBIT,
|
||||
* Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and tamper-evident (a
|
||||
* backwards total is refused; a monotonic {@code syncSeq} dedups replays). The cap is enforced at
|
||||
* the instance gate, not here. Gated behind {@code account-link.enabled}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
|
||||
@@ -19,9 +19,9 @@ import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Last-seen cumulative usage a linked self-hosted instance has reported for one {@code (team,
|
||||
* billing period, category)} (combined-billing "Mode A"). The instance reports monotonic cumulative
|
||||
* unit totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via
|
||||
* the standard charge path and advances this row. {@code lastSyncSeq} dedups replays.
|
||||
* billing period, category)} (combined billing). The instance reports monotonic cumulative unit
|
||||
* totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via the
|
||||
* standard charge path and advances this row. {@code lastSyncSeq} dedups replays.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.config.Customizer;
|
||||
@@ -71,6 +72,7 @@ public class SupabaseSecurityConfig {
|
||||
private final SaasTeamService saasTeamService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
private final Environment environment;
|
||||
|
||||
@Value("${app.supabase.issuer:}")
|
||||
private String issuer;
|
||||
@@ -105,6 +107,17 @@ public class SupabaseSecurityConfig {
|
||||
.permitAll()
|
||||
.requestMatchers("/actuator/health", "/api/v1/config/**")
|
||||
.permitAll()
|
||||
// Account-link connect handshake: an instance calls these
|
||||
// before it holds any credential, so there is nothing to
|
||||
// authenticate with yet. Neither grants anything on its
|
||||
// own — /request records an intent a human must approve,
|
||||
// and /claim requires a secret only the instance that
|
||||
// created the request has ever held.
|
||||
.requestMatchers(
|
||||
HttpMethod.POST,
|
||||
"/api/v1/account-link/connect/request",
|
||||
"/api/v1/account-link/connect/claim")
|
||||
.permitAll()
|
||||
.requestMatchers(
|
||||
req ->
|
||||
RequestUriUtils.isStaticResource(
|
||||
@@ -144,7 +157,7 @@ public class SupabaseSecurityConfig {
|
||||
SupabaseSecurityConfig
|
||||
::toAuthentication)));
|
||||
|
||||
// Device-credential auth for linked self-hosted instances (combined-billing Mode A).
|
||||
// Device-credential auth for linked self-hosted instances (combined billing).
|
||||
// The filter bean exists only when stirling.billing.account-link.enabled=true; when off it
|
||||
// is absent here, so the instance surface cannot authenticate at all until release.
|
||||
DeviceCredentialAuthenticationFilter deviceFilter =
|
||||
@@ -268,6 +281,28 @@ public class SupabaseSecurityConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loopback on any port, as Spring origin patterns. Only added outside production; see {@link
|
||||
* #corsConfigurationSource()}.
|
||||
*/
|
||||
private static final List<String> LOOPBACK_ANY_PORT =
|
||||
List.of("http://localhost:[*]", "http://127.0.0.1:[*]");
|
||||
|
||||
/**
|
||||
* Profiles that mean "a developer's machine or a preview environment", never the production
|
||||
* deployment. Production runs the bare {@code saas} profile.
|
||||
*/
|
||||
private static final List<String> NON_PRODUCTION_PROFILES = List.of("dev", "staging", "local");
|
||||
|
||||
private boolean isNonProduction() {
|
||||
for (String profile : environment.getActiveProfiles()) {
|
||||
if (NON_PRODUCTION_PROFILES.contains(profile)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Bean
|
||||
CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
@@ -297,7 +332,23 @@ public class SupabaseSecurityConfig {
|
||||
origins.add(desktopOrigin);
|
||||
}
|
||||
}
|
||||
if (origins.stream().anyMatch(o -> o.contains("*"))) {
|
||||
// Outside production, allow loopback on ANY port. Several dev servers run side by side
|
||||
// (editor, saas web app, one per flavour under test) and their ports move, so pinning a
|
||||
// list means every new local environment shows up as an opaque CORS failure. Unlike a
|
||||
// wildcard subdomain, a wildcard port on loopback cannot be taken over: nothing but this
|
||||
// machine can answer on it, so there is no lapsed-DNS or abandoned-vhost risk. Absent in
|
||||
// production, where the profile check below is false.
|
||||
if (!operatorOverride && isNonProduction()) {
|
||||
origins.addAll(LOOPBACK_ANY_PORT);
|
||||
log.info(
|
||||
"Non-production profile active: allowing loopback CORS origins on any port {}",
|
||||
LOOPBACK_ANY_PORT);
|
||||
}
|
||||
// Loopback port wildcards are exempt: the warning below is about hostname takeover, which
|
||||
// does not apply to an origin only this machine can serve.
|
||||
if (origins.stream()
|
||||
.filter(o -> !LOOPBACK_ANY_PORT.contains(o))
|
||||
.anyMatch(o -> o.contains("*"))) {
|
||||
log.warn(
|
||||
"CORS origins contain a wildcard paired with allowCredentials=true: {}."
|
||||
+ " Wildcard subdomains can be taken over by an attacker (lapsed DNS,"
|
||||
|
||||
@@ -519,8 +519,8 @@ public class SaasTeamService {
|
||||
* membership and its wallet) rather than deleting it, so a plain team is never orphaned. The
|
||||
* only real hazard is a team the user is the <em>last</em> leader of that still carries live
|
||||
* billing: an active paid/PAYG subscription, or a non-revoked linked self-hosted instance
|
||||
* ("Mode A"). Those block the join until the plan is cancelled / leadership transferred /
|
||||
* instances revoked. An unpaid, unlinked team (personal or shared) no longer blocks.
|
||||
* (combined billing). Those block the join until the plan is cancelled / leadership transferred
|
||||
* / instances revoked. An unpaid, unlinked team (personal or shared) no longer blocks.
|
||||
*
|
||||
* <p>The home team and the team being joined are excluded: neither is left by the join (home is
|
||||
* parked, the joined team is kept), so their live billing cannot be stranded.
|
||||
|
||||
+23
-23
@@ -1,6 +1,7 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -23,8 +24,7 @@ import stirling.software.proprietary.model.TeamMembership;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.accountlink.AccountLinkController.RegisterRequest;
|
||||
import stirling.software.saas.accountlink.AccountLinkController.RegisterResponse;
|
||||
import stirling.software.saas.accountlink.AccountLinkController.InstanceRow;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
|
||||
/**
|
||||
@@ -44,20 +44,27 @@ class AccountLinkControllerTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller = new AccountLinkController(service, memberRepo, userRepository);
|
||||
// Real resolver over the mocked repositories: the leader ladder moved into
|
||||
// LeaderTeamResolver, and these tests are still asserting that ladder's behaviour
|
||||
// through the controller.
|
||||
controller =
|
||||
new AccountLinkController(
|
||||
service, new LeaderTeamResolver(memberRepo, userRepository));
|
||||
auth =
|
||||
new AnonymousAuthenticationToken(
|
||||
"k", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_USER")));
|
||||
}
|
||||
|
||||
// The leader ladder used to be asserted through POST /register, which has been removed along
|
||||
// with the JWT relay. It is exercised through /instances instead: same resolver, same rungs.
|
||||
|
||||
@Test
|
||||
void register_unauthenticated_returns401() {
|
||||
void list_unauthenticated_returns401() {
|
||||
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
|
||||
.thenThrow(new SecurityException("not authenticated"));
|
||||
|
||||
ResponseEntity<RegisterResponse> resp =
|
||||
controller.register(new RegisterRequest("host"), auth);
|
||||
ResponseEntity<List<InstanceRow>> resp = controller.list(auth);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
verifyNoInteractions(service);
|
||||
@@ -65,14 +72,14 @@ class AccountLinkControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_noMembership_returns403() {
|
||||
void list_noMembership_returns403() {
|
||||
User user = mockUser(42L);
|
||||
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
|
||||
.thenReturn(user);
|
||||
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of());
|
||||
|
||||
ResponseEntity<RegisterResponse> resp = controller.register(null, auth);
|
||||
ResponseEntity<List<InstanceRow>> resp = controller.list(auth);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
verifyNoInteractions(service);
|
||||
@@ -80,7 +87,7 @@ class AccountLinkControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_nonLeader_returns403() {
|
||||
void list_nonLeader_returns403() {
|
||||
User user = mockUser(42L);
|
||||
TeamMembership member = membership(7L, TeamRole.MEMBER);
|
||||
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||
@@ -88,7 +95,7 @@ class AccountLinkControllerTest {
|
||||
.thenReturn(user);
|
||||
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(member));
|
||||
|
||||
ResponseEntity<RegisterResponse> resp = controller.register(null, auth);
|
||||
ResponseEntity<List<InstanceRow>> resp = controller.list(auth);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
verifyNoInteractions(service);
|
||||
@@ -96,27 +103,20 @@ class AccountLinkControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_leader_mintsCredentialForCallerTeam() {
|
||||
void list_leader_readsOnlyTheCallersTeam() {
|
||||
User user = mockUser(42L);
|
||||
TeamMembership leader = membership(7L, TeamRole.LEADER);
|
||||
when(service.register(7L, 42L, "host"))
|
||||
.thenReturn(
|
||||
new AccountLinkService.RegisteredInstance(99L, "dev-x", "sec-x", "host"));
|
||||
when(service.list(7L)).thenReturn(List.of());
|
||||
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
|
||||
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
|
||||
.thenReturn(user);
|
||||
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader));
|
||||
|
||||
ResponseEntity<RegisterResponse> resp =
|
||||
controller.register(new RegisterRequest("host"), auth);
|
||||
ResponseEntity<List<InstanceRow>> resp = controller.list(auth);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
RegisterResponse body = resp.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
// Team comes from the caller's membership and is surfaced in the response.
|
||||
assertThat(body.teamId()).isEqualTo(7L);
|
||||
assertThat(body.instanceId()).isEqualTo(99L);
|
||||
assertThat(body.deviceSecret()).isEqualTo("sec-x");
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
// The team comes from the caller's membership, never from the request.
|
||||
verify(service).list(7L);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
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.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.saas.accountlink.ConnectController.CreateBody;
|
||||
import stirling.software.saas.accountlink.ConnectController.CreateResponse;
|
||||
|
||||
/**
|
||||
* The authorize URL the instance is told to send its admin to. Everything else on this controller
|
||||
* delegates; this is the only decision it makes on its own.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ConnectControllerTest {
|
||||
|
||||
private static final CreateBody BODY =
|
||||
new CreateBody("prod-1", "https://pdf.example.com/account-link/callback", "n", "s");
|
||||
|
||||
@Mock private ConnectRequestService service;
|
||||
@Mock private LeaderTeamResolver leaderTeams;
|
||||
@Mock private AccountLinkService accountLinkService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private ConnectController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
controller =
|
||||
new ConnectController(
|
||||
service, leaderTeams, accountLinkService, applicationProperties);
|
||||
when(service.create(anyString(), anyString(), anyString(), anyString(), any()))
|
||||
.thenReturn(ConnectRequestService.CreateResult.ok("req-1", 1800));
|
||||
}
|
||||
|
||||
private String authorizeUrl(MockHttpServletRequest request) {
|
||||
Object body = controller.request(BODY, request).getBody();
|
||||
assertThat(body).isInstanceOf(CreateResponse.class);
|
||||
return ((CreateResponse) body).authorizeUrl();
|
||||
}
|
||||
|
||||
private static MockHttpServletRequest request(String scheme, String host, int port) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setScheme(scheme);
|
||||
request.setServerName(host);
|
||||
request.setServerPort(port);
|
||||
return request;
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefersTheConfiguredFrontendUrl() {
|
||||
applicationProperties.getSystem().setFrontendUrl("https://app.example.com/app/");
|
||||
|
||||
// Trailing slash trimmed, base path kept, and the API's own origin ignored.
|
||||
assertThat(authorizeUrl(request("https", "api.example.com", 443)))
|
||||
.isEqualTo("https://app.example.com/app/link?request=req-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToTheOriginTheApiWasReachedOn() {
|
||||
assertThat(authorizeUrl(request("https", "api.example.com", 443)))
|
||||
.isEqualTo("https://api.example.com/link?request=req-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsANonDefaultPortAndTheContextPath() {
|
||||
MockHttpServletRequest request = request("http", "localhost", 8081);
|
||||
request.setContextPath("/stirling");
|
||||
|
||||
assertThat(authorizeUrl(request))
|
||||
.isEqualTo("http://localhost:8081/stirling/link?request=req-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void honoursTheForwardedSchemeAndHost() {
|
||||
MockHttpServletRequest request = request("http", "10.0.0.5", 8080);
|
||||
request.addHeader("X-Forwarded-Proto", "https");
|
||||
request.addHeader("X-Forwarded-Host", "api.example.com");
|
||||
|
||||
assertThat(authorizeUrl(request)).isEqualTo("https://api.example.com/link?request=req-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void takesOnlyTheFirstForwardedHop() {
|
||||
MockHttpServletRequest request = request("http", "10.0.0.5", 8080);
|
||||
request.addHeader("X-Forwarded-Proto", "https, http");
|
||||
request.addHeader("X-Forwarded-Host", "api.example.com, evil.example.com");
|
||||
|
||||
assertThat(authorizeUrl(request)).isEqualTo("https://api.example.com/link?request=req-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void percentEncodesTheRequestId() {
|
||||
when(service.create(anyString(), anyString(), anyString(), anyString(), any()))
|
||||
.thenReturn(ConnectRequestService.CreateResult.ok("a b&c", 1800));
|
||||
|
||||
assertThat(authorizeUrl(request("https", "api.example.com", 443)))
|
||||
.isEqualTo("https://api.example.com/link?request=a+b%26c");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBodylessRequestIsRejectedBeforeAnythingIsRecorded() {
|
||||
assertThat(controller.request(null, request("https", "api.example.com", 443)).getBody())
|
||||
.isEqualTo(java.util.Map.of("error", "BAD_REQUEST"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void offeringNoCredentialTakesTheFirstLinkPath() {
|
||||
authorizeUrl(request("https", "api.example.com", 443));
|
||||
|
||||
// createReauth is the credentialled path; a first link must not reach it.
|
||||
org.mockito.Mockito.verify(service, org.mockito.Mockito.never())
|
||||
.createReauth(anyString(), anyString(), anyString(), anyString(), any(), isNull());
|
||||
}
|
||||
}
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import stirling.software.saas.accountlink.ConnectRequestService.ClaimOutcome;
|
||||
import stirling.software.saas.accountlink.ConnectRequestService.CreateRejection;
|
||||
|
||||
/**
|
||||
* Unit tests for the connect handshake's security properties, which are the reason this flow is
|
||||
* safe rather than an open redirect: the callback is validated once and then read back from
|
||||
* storage, the claim secret authenticates the collection, and one approval mints exactly one
|
||||
* credential.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ConnectRequestServiceTest {
|
||||
|
||||
private static final String CALLBACK = "https://pdf.example.com/account-link/callback";
|
||||
private static final String NONCE = "nonce-value";
|
||||
private static final String CLAIM_SECRET = "claim-secret-value";
|
||||
|
||||
@Mock private ConnectRequestRepository repo;
|
||||
@Mock private AccountLinkService accountLinkService;
|
||||
|
||||
private ConnectRequestService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new ConnectRequestService(repo, accountLinkService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_storesTheValidatedCallbackAndItsOrigin() {
|
||||
ConnectRequestService.CreateResult result =
|
||||
service.create("prod-1", CALLBACK, NONCE, CLAIM_SECRET, "10.0.0.1");
|
||||
|
||||
assertThat(result.isRejected()).isFalse();
|
||||
assertThat(result.requestId()).isNotBlank();
|
||||
|
||||
ArgumentCaptor<ConnectRequest> saved = ArgumentCaptor.forClass(ConnectRequest.class);
|
||||
verify(repo).save(saved.capture());
|
||||
ConnectRequest row = saved.getValue();
|
||||
assertThat(row.getCallbackUrl()).isEqualTo(CALLBACK);
|
||||
assertThat(row.getCallbackOrigin()).isEqualTo("https://pdf.example.com");
|
||||
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.PENDING);
|
||||
assertThat(row.getName()).isEqualTo("prod-1");
|
||||
// The claim secret is only ever stored as a hash.
|
||||
assertThat(row.getClaimSecretHash()).isNotEqualTo(CLAIM_SECRET).hasSize(64);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_keepsANonDefaultPortInTheOrigin() {
|
||||
service.create(
|
||||
null, "http://pdf.internal:8080/account-link/callback", NONCE, CLAIM_SECRET, null);
|
||||
|
||||
ArgumentCaptor<ConnectRequest> saved = ArgumentCaptor.forClass(ConnectRequest.class);
|
||||
verify(repo).save(saved.capture());
|
||||
assertThat(saved.getValue().getCallbackOrigin()).isEqualTo("http://pdf.internal:8080");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"/account-link/callback", // not absolute
|
||||
"ftp://pdf.example.com/cb", // wrong scheme
|
||||
"javascript:alert(1)", // not a hierarchical http(s) URL
|
||||
"https://user:pw@pdf.example.com/cb", // credentials in the URL
|
||||
"https://pdf.example.com/cb#already", // would collide with our fragment
|
||||
"https:///cb" // no host
|
||||
})
|
||||
void create_refusesCallbacksWeWouldNotWantToRedirectTo(String callback) {
|
||||
ConnectRequestService.CreateResult result =
|
||||
service.create(null, callback, NONCE, CLAIM_SECRET, null);
|
||||
|
||||
assertThat(result.rejection()).isEqualTo(CreateRejection.BAD_CALLBACK);
|
||||
verify(repo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_refusesAMissingNonce() {
|
||||
assertThat(service.create(null, CALLBACK, " ", CLAIM_SECRET, null).rejection())
|
||||
.isEqualTo(CreateRejection.BAD_NONCE);
|
||||
verify(repo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_namesTheSecretWhenTheSecretIsWhatIsMissing() {
|
||||
assertThat(service.create(null, CALLBACK, NONCE, " ", null).rejection())
|
||||
.isEqualTo(CreateRejection.BAD_SECRET);
|
||||
verify(repo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_isCappedPerSourceAddress() {
|
||||
when(repo.countByRequesterIpAndCreatedAtAfter(anyString(), any()))
|
||||
.thenReturn((long) ConnectRequestService.MAX_REQUESTS_PER_IP);
|
||||
|
||||
ConnectRequestService.CreateResult result =
|
||||
service.create(null, CALLBACK, NONCE, CLAIM_SECRET, "10.0.0.1");
|
||||
|
||||
assertThat(result.rejection()).isEqualTo(CreateRejection.RATE_LIMITED);
|
||||
verify(repo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void lookup_flagsPlaintextTransportSoTheApproverCanSeeIt() {
|
||||
ConnectRequest row = pending();
|
||||
row.setCallbackOrigin("http://pdf.internal:8080");
|
||||
when(repo.findByRequestId("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.lookup("req")).get().extracting("insecureTransport").isEqualTo(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void lookup_hidesAnExpiredHandshake() {
|
||||
ConnectRequest row = pending();
|
||||
row.setExpiresAt(LocalDateTime.now().minusMinutes(1));
|
||||
when(repo.findByRequestId("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.lookup("req")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void approve_bindsTheTeamAndReturnsTheStoredCallback() {
|
||||
ConnectRequest row = pending();
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
ConnectRequestService.ApproveResult result = service.approve("req", 7L, 42L);
|
||||
|
||||
assertThat(result.isRejected()).isFalse();
|
||||
// The destination comes from the row, never from the caller.
|
||||
assertThat(result.target().callbackUrl()).isEqualTo(CALLBACK);
|
||||
assertThat(result.target().nonce()).isEqualTo(NONCE);
|
||||
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED);
|
||||
assertThat(row.getTeamId()).isEqualTo(7L);
|
||||
assertThat(row.getApprovedByUserId()).isEqualTo(42L);
|
||||
// Approval on its own must not mint anything.
|
||||
verifyNoInteractions(accountLinkService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void approve_isSingleUse() {
|
||||
ConnectRequest row = pending();
|
||||
row.setStatus(ConnectRequest.Status.APPROVED);
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.approve("req", 7L, 42L).isRejected()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void approve_refusesAnExpiredHandshake() {
|
||||
ConnectRequest row = pending();
|
||||
row.setExpiresAt(LocalDateTime.now().minusSeconds(1));
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.approve("req", 7L, 42L).isRejected()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createReauth_pinsTheTeamItWasToldByTheCredential() {
|
||||
ConnectRequestService.CreateResult result =
|
||||
service.createReauth(null, CALLBACK, NONCE, CLAIM_SECRET, null, 7L);
|
||||
|
||||
assertThat(result.isRejected()).isFalse();
|
||||
ArgumentCaptor<ConnectRequest> saved = ArgumentCaptor.forClass(ConnectRequest.class);
|
||||
verify(repo).save(saved.capture());
|
||||
assertThat(saved.getValue().getMode()).isEqualTo(ConnectRequest.Mode.REAUTH);
|
||||
assertThat(saved.getValue().getTeamId()).isEqualTo(7L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createReauth_withoutAnAuthenticatedInstanceIsRefused() {
|
||||
// The controller passes null when the offered device credential did not authenticate.
|
||||
assertThat(
|
||||
service.createReauth(null, CALLBACK, NONCE, CLAIM_SECRET, null, null)
|
||||
.rejection())
|
||||
.isEqualTo(CreateRejection.NOT_LINKED);
|
||||
verify(repo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_leavesTheTeamOpenForAFirstLink() {
|
||||
service.create("n", CALLBACK, NONCE, CLAIM_SECRET, null);
|
||||
|
||||
ArgumentCaptor<ConnectRequest> saved = ArgumentCaptor.forClass(ConnectRequest.class);
|
||||
verify(repo).save(saved.capture());
|
||||
assertThat(saved.getValue().getMode()).isEqualTo(ConnectRequest.Mode.LINK);
|
||||
// Approval is what decides the team on a first link.
|
||||
assertThat(saved.getValue().getTeamId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void approve_refusesAnApproverFromADifferentTeam() {
|
||||
ConnectRequest row = reauthPinnedTo(7L);
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
ConnectRequestService.ApproveResult result = service.approve("req", 99L, 42L);
|
||||
|
||||
// This is the "signed in to the wrong account" case, and it must not silently rebind.
|
||||
assertThat(result.rejection()).isEqualTo(ConnectRequestService.ApproveRejection.WRONG_TEAM);
|
||||
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.PENDING);
|
||||
assertThat(row.getTeamId()).isEqualTo(7L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void approve_acceptsTheTeamTheServerAlreadyBelongsTo() {
|
||||
ConnectRequest row = reauthPinnedTo(7L);
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.approve("req", 7L, 42L).isRejected()).isFalse();
|
||||
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claim_onAReauthConfirmsWithoutMintingASecondCredential() {
|
||||
ConnectRequest row = reauthPinnedTo(7L);
|
||||
row.setStatus(ConnectRequest.Status.APPROVED);
|
||||
row.setApprovedByUserId(42L);
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
ConnectRequestService.ClaimResult result = service.claim("req", CLAIM_SECRET);
|
||||
|
||||
assertThat(result.outcome()).isEqualTo(ClaimOutcome.CONFIRMED);
|
||||
assertThat(result.deviceId()).isNull();
|
||||
assertThat(result.deviceSecret()).isNull();
|
||||
assertThat(result.teamId()).isEqualTo(7L);
|
||||
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.CONSUMED);
|
||||
// A second credential would orphan the one the instance already holds.
|
||||
verifyNoInteractions(accountLinkService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claim_mintsOnceForAnApprovedHandshake() {
|
||||
ConnectRequest row = approved();
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
when(accountLinkService.register(anyLong(), anyLong(), any()))
|
||||
.thenReturn(
|
||||
new AccountLinkService.RegisteredInstance(9L, "dev-id", "dev-secret", "n"));
|
||||
|
||||
ConnectRequestService.ClaimResult result = service.claim("req", CLAIM_SECRET);
|
||||
|
||||
assertThat(result.outcome()).isEqualTo(ClaimOutcome.GRANTED);
|
||||
assertThat(result.deviceId()).isEqualTo("dev-id");
|
||||
assertThat(result.deviceSecret()).isEqualTo("dev-secret");
|
||||
assertThat(result.teamId()).isEqualTo(7L);
|
||||
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.CONSUMED);
|
||||
verify(accountLinkService).register(7L, 42L, "n");
|
||||
}
|
||||
|
||||
@Test
|
||||
void claim_refusesASecondCollection() {
|
||||
ConnectRequest row = approved();
|
||||
row.setStatus(ConnectRequest.Status.CONSUMED);
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
|
||||
verifyNoInteractions(accountLinkService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claim_withTheWrongSecretMintsNothing() {
|
||||
ConnectRequest row = approved();
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.claim("req", "not-the-secret").outcome())
|
||||
.isEqualTo(ClaimOutcome.REJECTED);
|
||||
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED);
|
||||
verifyNoInteractions(accountLinkService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claim_beforeApprovalTellsTheInstanceToWait() {
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(pending()));
|
||||
|
||||
assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.PENDING);
|
||||
verifyNoInteractions(accountLinkService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claim_afterDenialIsTerminal() {
|
||||
ConnectRequest row = pending();
|
||||
row.setStatus(ConnectRequest.Status.DENIED);
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
|
||||
verifyNoInteractions(accountLinkService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claim_onAnExpiredHandshakeMintsNothing() {
|
||||
ConnectRequest row = approved();
|
||||
row.setExpiresAt(LocalDateTime.now().minusSeconds(1));
|
||||
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
|
||||
|
||||
assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
|
||||
verifyNoInteractions(accountLinkService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claim_forAnUnknownIdLooksTheSameAsABadSecret() {
|
||||
when(repo.findByRequestIdForUpdate("nope")).thenReturn(Optional.empty());
|
||||
|
||||
assertThat(service.claim("nope", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
private static ConnectRequest pending() {
|
||||
ConnectRequest row = new ConnectRequest();
|
||||
row.setRequestId("req");
|
||||
row.setName("n");
|
||||
row.setCallbackUrl(CALLBACK);
|
||||
row.setCallbackOrigin("https://pdf.example.com");
|
||||
row.setNonce(NONCE);
|
||||
row.setClaimSecretHash(AccountLinkService.sha256Hex(CLAIM_SECRET));
|
||||
row.setStatus(ConnectRequest.Status.PENDING);
|
||||
row.setExpiresAt(LocalDateTime.now().plusMinutes(10));
|
||||
return row;
|
||||
}
|
||||
|
||||
/** A re-authentication whose team came from the instance's credential, not from a browser. */
|
||||
private static ConnectRequest reauthPinnedTo(Long teamId) {
|
||||
ConnectRequest row = pending();
|
||||
row.setMode(ConnectRequest.Mode.REAUTH);
|
||||
row.setTeamId(teamId);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static ConnectRequest approved() {
|
||||
ConnectRequest row = pending();
|
||||
row.setStatus(ConnectRequest.Status.APPROVED);
|
||||
row.setTeamId(7L);
|
||||
row.setApprovedByUserId(42L);
|
||||
row.setApprovedAt(LocalDateTime.now());
|
||||
return row;
|
||||
}
|
||||
}
|
||||
+53
-1
@@ -14,6 +14,8 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
@@ -48,13 +50,19 @@ class SupabaseSecurityConfigMoreTest {
|
||||
apiKeyAuthenticationService;
|
||||
|
||||
private SupabaseSecurityConfig config(ApplicationProperties props) {
|
||||
return config(props, new MockEnvironment());
|
||||
}
|
||||
|
||||
/** Loopback CORS origins are only added outside production, so the environment decides. */
|
||||
private SupabaseSecurityConfig config(ApplicationProperties props, Environment environment) {
|
||||
return new SupabaseSecurityConfig(
|
||||
userService,
|
||||
teamService,
|
||||
supabaseUserService,
|
||||
saasTeamService,
|
||||
props,
|
||||
apiKeyAuthenticationService);
|
||||
apiKeyAuthenticationService,
|
||||
environment);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -204,6 +212,50 @@ class SupabaseSecurityConfigMoreTest {
|
||||
.hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("production does not allow loopback on arbitrary ports")
|
||||
void productionHasNoLoopbackWildcard() {
|
||||
CorsConfiguration cfg =
|
||||
cors(config(new ApplicationProperties()).corsConfigurationSource());
|
||||
|
||||
assertThat(cfg.getAllowedOriginPatterns())
|
||||
.doesNotContain("http://localhost:[*]", "http://127.0.0.1:[*]");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-production allows loopback on any port so dev servers can move")
|
||||
void devAllowsAnyLoopbackPort() {
|
||||
// Several dev servers run side by side and their ports change; pinning a list turns
|
||||
// every new local environment into an opaque CORS failure.
|
||||
MockEnvironment dev = new MockEnvironment();
|
||||
dev.setActiveProfiles("saas", "dev");
|
||||
|
||||
CorsConfiguration cfg =
|
||||
cors(config(new ApplicationProperties(), dev).corsConfigurationSource());
|
||||
|
||||
assertThat(cfg.getAllowedOriginPatterns())
|
||||
.contains("http://localhost:[*]", "http://127.0.0.1:[*]")
|
||||
// Still credentialed, which is the reason the pattern form matters.
|
||||
.contains("https://stirling.com");
|
||||
assertThat(cfg.getAllowCredentials()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an operator origin list is respected verbatim even in dev")
|
||||
void operatorOverrideSuppressesLoopbackWildcard() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSystem().setCorsAllowedOrigins(List.of("https://custom.example.com"));
|
||||
MockEnvironment dev = new MockEnvironment();
|
||||
dev.setActiveProfiles("saas", "dev");
|
||||
|
||||
CorsConfiguration cfg = cors(config(props, dev).corsConfigurationSource());
|
||||
|
||||
// An operator who set the list meant it; we do not widen it behind their back.
|
||||
assertThat(cfg.getAllowedOriginPatterns())
|
||||
.contains("https://custom.example.com")
|
||||
.doesNotContain("http://localhost:[*]");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("operator override replaces the default origin list")
|
||||
void operatorOverrideUsed() {
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ ext {
|
||||
commonsIoVersion = "2.22.0"
|
||||
commonsLang3 = "3.20.0"
|
||||
rhinoVersion = "1.9.1"
|
||||
okhttpBomVersion = "5.3.2"
|
||||
okhttpBomVersion = "5.4.0"
|
||||
gsonVersion = "2.14.0"
|
||||
guavaVersion = "33.6.0-jre"
|
||||
jinjavaVersion = "2.8.4"
|
||||
|
||||
@@ -4,7 +4,7 @@ ARG BASE_VERSION=1.0.2@sha256:c7698687f486707ddef9e0298587ca8b44c4e96185e1bdb0c3
|
||||
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
|
||||
|
||||
# Stage 1: Build the Java application (backend only, no frontend)
|
||||
FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS app-build
|
||||
FROM gradle:9.7.1-jdk25@sha256:d868117760a7c92214705f47ed173116a5d13e58d68702f974ff30acd062737e AS app-build
|
||||
|
||||
# JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead
|
||||
ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
|
||||
|
||||
@@ -5,7 +5,7 @@ ARG BASE_VERSION=1.0.2@sha256:c7698687f486707ddef9e0298587ca8b44c4e96185e1bdb0c3
|
||||
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
|
||||
|
||||
# Stage 1: Build the Java application and frontend
|
||||
FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS app-build
|
||||
FROM gradle:9.7.1-jdk25@sha256:d868117760a7c92214705f47ed173116a5d13e58d68702f974ff30acd062737e AS app-build
|
||||
|
||||
ARG TASK_VERSION=3.52.0
|
||||
RUN apt-get update \
|
||||
|
||||
@@ -8,7 +8,7 @@ ARG BASE_VERSION=1.0.2@sha256:c7698687f486707ddef9e0298587ca8b44c4e96185e1bdb0c3
|
||||
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
|
||||
|
||||
# Stage 1: Build the Java application and frontend
|
||||
FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS app-build
|
||||
FROM gradle:9.7.1-jdk25@sha256:d868117760a7c92214705f47ed173116a5d13e58d68702f974ff30acd062737e AS app-build
|
||||
|
||||
ARG TASK_VERSION=3.52.0
|
||||
RUN apt-get update \
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# Single JAR contains both frontend and backend with minimal dependencies
|
||||
|
||||
# Stage 1: Build application with embedded frontend
|
||||
FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS build
|
||||
FROM gradle:9.7.1-jdk25@sha256:d868117760a7c92214705f47ed173116a5d13e58d68702f974ff30acd062737e AS build
|
||||
|
||||
# Install Node.js and npm for frontend build
|
||||
ARG TASK_VERSION=3.52.0
|
||||
|
||||
@@ -41,7 +41,7 @@ false = "خطأ"
|
||||
fileNotSavedToDisk = "لم يُحفَظ على القرص"
|
||||
fileSavedToDisk = "تم حفظ الملف على القرص"
|
||||
fileSelected = "المحدد: {{filename}}"
|
||||
filesSelected = "الملفات المحددة"
|
||||
filesSelected = "{{count}} ملفات"
|
||||
font = "الخط"
|
||||
fontSizeTooltip = "حجم نص رقم الصفحة بالنقاط. الأرقام الأكبر تنتج نصًا أكبر."
|
||||
fontTypeTooltip = "عائلة الخط لأرقام الصفحات. اختر بناءً على نمط مستندك."
|
||||
@@ -3969,6 +3969,7 @@ back = "رجوع"
|
||||
backToFolder = "الرجوع إلى {{folder}}"
|
||||
backToMyFiles = "الرجوع إلى ملفاتي"
|
||||
breadcrumbs = "مسار المجلد"
|
||||
bulkActions = "الإجراءات"
|
||||
cancel = "إلغاء"
|
||||
classification = "التصنيف"
|
||||
clearSelection = "مسح التحديد"
|
||||
@@ -4142,6 +4143,11 @@ totalSize = "الحجم الإجمالي"
|
||||
type = "النوع"
|
||||
versionHistory = "رحلة الإصدارات"
|
||||
|
||||
[filesPage.filters]
|
||||
activeCount = "{{count}} عوامل تصفية نشطة"
|
||||
clearAll = "مسح عوامل التصفية"
|
||||
label = "عوامل التصفية"
|
||||
|
||||
[filesPage.folderName]
|
||||
cancel = "إلغاء"
|
||||
error = "تعذّر حفظ المجلد. حاول مرة أخرى."
|
||||
@@ -4176,6 +4182,7 @@ label = "Filter files by name"
|
||||
placeholder = "Filter files…"
|
||||
|
||||
[filesPage.sort]
|
||||
label = "فرز الملفات"
|
||||
modifiedAsc = "الأقدم أولاً"
|
||||
modifiedDesc = "الأحدث أولاً"
|
||||
nameAsc = "الاسم A→Z"
|
||||
@@ -4305,11 +4312,14 @@ issues = "GitHub"
|
||||
[formFill]
|
||||
allSaved = "تم حفظ الكل"
|
||||
analyzingFields = "Analysing form fields..."
|
||||
applyFailed = "تعذّر تطبيق التغييرات"
|
||||
extractCsvError = "فشل استخراج CSV"
|
||||
extractXlsxError = "فشل استخراج XLSX"
|
||||
filled = "filled"
|
||||
flattenAfterFilling = "تسطيح بعد التعبئة"
|
||||
goToPage = "الانتقال إلى هذه الصفحة"
|
||||
noFields = "No fillable form fields found in this PDF."
|
||||
page = "الصفحة"
|
||||
placeholderEnter = "Enter"
|
||||
placeholderSelect = "Select"
|
||||
requiredAbbreviation = "مطلوب"
|
||||
@@ -4318,8 +4328,83 @@ rescanFields = "إعادة فحص الحقول"
|
||||
rescanFormFields = "إعادة فحص حقول النموذج"
|
||||
save = "حفظ"
|
||||
saveShortcut = "Ctrl+S للحفظ"
|
||||
skippedEdits_one = "تعذّر تطبيق تغيير واحد:"
|
||||
skippedEdits_other = "تعذّر تطبيق {{count}} تغييرات:"
|
||||
skippedEditsTruncated = "{{count}} إضافية غير مدرجة."
|
||||
unsavedChanges = "تغييرات غير محفوظة"
|
||||
|
||||
[formFill.create]
|
||||
commit = "إضافة {{count}} من الحقول إلى PDF"
|
||||
empty = "لم يتم رسم أي حقول بعد."
|
||||
failed = "فشل في إضافة الحقول"
|
||||
goToField = "الانتقال إلى هذا الحقل"
|
||||
hint = "اختر نوع الحقل، ثم ارسمه على الصفحة."
|
||||
placing = "ارسم حقل {{type}} على الصفحة. اضغط على Esc للتوقف."
|
||||
preview = "اضغط مطولًا للمعاينة"
|
||||
previewHelp = "اضغط مطولًا لرؤية الحقول كما ستبدو بعد إضافتها، دون حدود التحرير."
|
||||
removeField = "إزالة الحقل"
|
||||
|
||||
[formFill.editor]
|
||||
action = "إجراء الزر"
|
||||
actionHelp = "ما يفعله الزر عند النقر عليه."
|
||||
actionNone = "لا شيء"
|
||||
actionPrint = "طباعة"
|
||||
actionReset = "إعادة تعيين النموذج"
|
||||
actionSubmit = "إرسال إلى URL"
|
||||
actionUri = "فتح URL"
|
||||
actionUrl = "URL"
|
||||
actionUrlHelp = "العنوان الذي يفتحه الزر أو يرسل إليه."
|
||||
addOption = "إضافة خيار"
|
||||
caption = "نص الزر"
|
||||
captionHelp = "النص المطبوع على واجهة الزر."
|
||||
defaultValue = "القيمة الافتراضية"
|
||||
defaultValueHelp = "ما يحتويه الحقل قبل أن يملأه أي شخص. اتركه فارغًا لحقل فارغ."
|
||||
fontSize = "حجم الخط"
|
||||
fontSizeHelp = "حجم النص داخل الحقل. اتركه فارغًا للسماح للقارئ بتغيير حجمه ليناسب المساحة."
|
||||
label = "التسمية"
|
||||
labelHelp = "النص المعروض لمن يملأ النموذج. اتركه فارغًا للرجوع إلى اسم الحقل."
|
||||
maxLength = "الحد الأقصى للطول (comb)"
|
||||
maxLengthHelp = "يحدد عدد الأحرف التي يمكن احتواؤها، مع رسمها كمربعات متباعدة بالتساوي."
|
||||
multiline = "متعدد الأسطر"
|
||||
multilineHelp = "يسمح بأكثر من سطر واحد من النص ويلتف عند حافة الحقل."
|
||||
multiSelect = "السماح بتحديد متعدد"
|
||||
multiSelectHelp = "يسمح باختيار أكثر من خيار واحد في الوقت نفسه."
|
||||
name = "اسم الحقل"
|
||||
nameHelp = "الاسم الداخلي للحقل. يُستخدم عند تصدير البيانات أو ملء النموذج من نظام آخر، لذا اجعله فريدًا وخاليًا من المسافات."
|
||||
optionGap = "تباعد الخيارات"
|
||||
optionGapHelp = "المسافة بين الأزرار، بالنقاط. اتركها فارغة لتوزيعها بالتساوي على طول المربع."
|
||||
optionPlaceholder = "الخيار {{n}}"
|
||||
options = "الخيارات"
|
||||
optionsEmpty = "أضف خيارًا واحدًا على الأقل."
|
||||
optionsHelp = "الاختيارات المعروضة في القائمة. يتم تخزين كل اختيار كما كُتب، لذا اجعلها قصيرة ومميزة."
|
||||
optionSize = "حجم الخيار"
|
||||
optionSizeHelp = "عرض وارتفاع كل زر، بالنقاط. اتركه فارغًا لملاءمتها مع المربع الذي رسمته."
|
||||
readOnly = "للقراءة فقط"
|
||||
readOnlyHelp = "يعرض قيمة لكنه يمنع أي شخص من تعديلها."
|
||||
removeOption = "إزالة الخيار"
|
||||
required = "مطلوب"
|
||||
requiredHelp = "لا يمكن إرسال النموذج حتى يتم ملء هذا الحقل."
|
||||
signatureNote = "عنصر نائب فقط - لا توقّع هنا. إنه يحدد مكان وجود التوقيع حتى يضع موقّع PDF (Adobe Acrobat، خدمة توقيع، وما إلى ذلك) التوقيع في هذا الموضع عند توقيع المستند."
|
||||
tooltip = "تلميح"
|
||||
tooltipHelp = "التلميح المعروض عندما يمرر شخص المؤشر فوق الحقل في قارئ PDF."
|
||||
type = "النوع"
|
||||
typeHelp = "نوع هذا الحقل. يؤدي تغييره إلى إعادة إنشاء الحقل، لذا لن يتم نقل قيمته الحالية."
|
||||
|
||||
[formFill.mode]
|
||||
create = "إنشاء"
|
||||
fill = "ملء"
|
||||
label = "وضع محرر النماذج"
|
||||
modify = "تعديل"
|
||||
|
||||
[formFill.modify]
|
||||
commit = "حفظ {{count}} من التغييرات"
|
||||
delete = "حذف"
|
||||
empty = "لا يحتوي ملف PDF هذا على أي حقول نموذج بعد."
|
||||
failed = "فشل في حفظ التغييرات"
|
||||
groupSizeHint = "استخدم حجم الخيار"
|
||||
hint = "حدد حقلًا لتعديل خصائصه، أو اسحبه على الصفحة، أو احذفه."
|
||||
restore = "استعادة"
|
||||
|
||||
[formFill.sidebar]
|
||||
close = "إغلاق الشريط الجانبي"
|
||||
|
||||
@@ -4585,6 +4670,10 @@ desc = "تغيير قيود المستند وأذوناته"
|
||||
tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights"
|
||||
title = "تغيير الأذونات"
|
||||
|
||||
[home.classify]
|
||||
desc = "تحديد نوع هذا المستند ووضع علامة عليه."
|
||||
title = "تصنيف"
|
||||
|
||||
[home.compare]
|
||||
desc = "يقارن ويظهر الاختلافات بين مستندين PDF"
|
||||
tags = "اختلاف"
|
||||
@@ -5098,6 +5187,30 @@ openProcessor = "Open PDF Processor"
|
||||
count = "{{remaining}} of {{total}}"
|
||||
label = "Free credits"
|
||||
|
||||
[notifications]
|
||||
empty = "لا يوجد ما يمكن الإبلاغ عنه."
|
||||
handoffUnavailable = "لن يسمح هذا المتصفح للمعالج بتمرير المستند إلى المحرر. افتحه من المحرر بدلًا من ذلك."
|
||||
noDocumentLinked = "هذا الفشل غير مرتبط بمستند محدد، لذا لا يوجد ما يمكن فتحه هنا."
|
||||
notOnThisDevice = "هذا المستند غير موجود على هذا الجهاز، لذا لا يمكن فتحه هنا."
|
||||
occurrences = "{{count}} مرات"
|
||||
open = "الإشعارات"
|
||||
title = "الإشعارات"
|
||||
unread = "غير مقروء"
|
||||
|
||||
[notifications.action]
|
||||
failed = "لم ينجح ذلك. حاول مرة أخرى بعد قليل."
|
||||
unavailable = "غير متاح لهذا الإشعار."
|
||||
|
||||
[notifications.detail]
|
||||
copied = "تم النسخ"
|
||||
copy = "نسخ الخطأ"
|
||||
less = "عرض أقل"
|
||||
more = "عرض الرسالة كاملة"
|
||||
|
||||
[notifications.section]
|
||||
earlier = "سابقًا"
|
||||
new = "جديد"
|
||||
|
||||
[oauth.error]
|
||||
message = "لم تتم المصادقة بنجاح. يمكنك إغلاق هذه النافذة والمحاولة مجدداً."
|
||||
title = "فشلت المصادقة"
|
||||
@@ -6735,8 +6848,8 @@ barAria = "Free PDFs remaining"
|
||||
capSuffix_one = "من {{allowance}} ملفات PDF مجانية مستخدمة"
|
||||
capSuffix_other = "من {{allowance}} ملفات PDF مجانية مستخدمة"
|
||||
eyebrow = "تجربة Processor"
|
||||
statusLabel_one = "متبقٍ {{remaining}}"
|
||||
statusLabel_other = "متبقٍ {{remaining}}"
|
||||
statusLabel_one = "تم استخدام {{used}}"
|
||||
statusLabel_other = "تم استخدام {{used}}"
|
||||
sub = "استخدم محرر PDF مجانًا. ادفع لمعالجة ملفات PDF تلقائيًا."
|
||||
title_one = "عالِج {{allowance}} ملف PDF مجانًا"
|
||||
title_other = "عالِج {{allowance}} ملفات PDF مجانًا"
|
||||
@@ -7417,6 +7530,8 @@ acknowledge = "Acknowledge"
|
||||
confirm = "Are you sure?"
|
||||
dismiss = "Dismiss"
|
||||
dismissSkipFile = "Skip this file"
|
||||
viewFile = "عرض الملف"
|
||||
viewInProcessor = "عرض في المعالج"
|
||||
|
||||
[portal.failures.debug]
|
||||
copyJson = "Copy JSON"
|
||||
@@ -7428,6 +7543,8 @@ showJson = "Show raw JSON ({{total}})"
|
||||
|
||||
[portal.failures.disabled]
|
||||
closed = "This failure is already closed."
|
||||
noDocument = "لم يتم تسجيل هذا الفشل مقابل مستند محدد، لذا لا يوجد شيء هنا لفتحه."
|
||||
unattended = "تم إدخال هذا الملف بواسطة مجلد أو حاوية أو webhook، لذا لا يحتفظ به متصفح أي شخص لفتحه."
|
||||
unavailable = "Not available for this failure."
|
||||
|
||||
[portal.failures.empty]
|
||||
@@ -8716,7 +8833,9 @@ title = "المصادر"
|
||||
connectSource = "توصيل مصدر"
|
||||
|
||||
[portal.sources.builder]
|
||||
advanced = "متقدم"
|
||||
back = "العودة إلى المصادر"
|
||||
backToSource = "العودة إلى إعداد المصدر"
|
||||
backToTypes = "كل أنواع المصادر"
|
||||
cancel = "إلغاء"
|
||||
chooseHint = "اختر من أين تأتي المستندات. الموصلات باللون الرمادي قادمة قريبًا."
|
||||
@@ -8726,7 +8845,6 @@ create = "إنشاء مصدر"
|
||||
createTitle = "اتصال بمصدر"
|
||||
delete = "حذف"
|
||||
editTitle = "تعديل المصدر"
|
||||
enabled = "مفعل"
|
||||
save = "حفظ التغييرات"
|
||||
|
||||
[portal.sources.builder.folderAccess]
|
||||
@@ -8763,6 +8881,7 @@ consume = "استهلاك: معالجة كل ملف مرة واحدة"
|
||||
snapshot = "لقطة: إعادة قراءة المجلد في كل تشغيل"
|
||||
|
||||
[portal.sources.networkFields.recursive]
|
||||
helperText = "قم بتضمين المجلدات الفرعية إذا كانت ملفاتك منظمة داخل مجلدات متداخلة، أو راقب المستوى الأعلى فقط."
|
||||
label = "عمق المجلد"
|
||||
|
||||
[portal.sources.networkFields.recursive.options]
|
||||
@@ -8816,6 +8935,7 @@ hash = "الحجم والتاريخ والتحقق من المحتوى"
|
||||
stat = "الحجم وتاريخ التعديل"
|
||||
|
||||
[portal.sources.types.folder.fields.mode]
|
||||
helperText = "ما إذا كان سيتم ترك الملف الأصلي في مكانه بعد معالجته. إذا تُرك الملف الأصلي في مكانه، فستتم إعادة معالجته في المرة التالية التي يفحص فيها مسار المعالجة المصدر."
|
||||
label = "وضع القراءة"
|
||||
|
||||
[portal.sources.types.folder.fields.mode.options]
|
||||
@@ -8823,6 +8943,7 @@ consume = "استهلاك: معالجة كل ملف مرة واحدة"
|
||||
snapshot = "لقطة: إعادة قراءة المجلد في كل تشغيل"
|
||||
|
||||
[portal.sources.types.folder.fields.recursive]
|
||||
helperText = "قم بتضمين المجلدات الفرعية إذا كانت ملفاتك منظمة داخل مجلدات متداخلة، أو راقب المستوى الأعلى فقط."
|
||||
label = "عمق المجلد"
|
||||
|
||||
[portal.sources.types.folder.fields.recursive.options]
|
||||
@@ -9696,7 +9817,9 @@ toolNotAvailableLocally = "خادم Stirling-PDF الخاص بك غير متصل
|
||||
expired = "لقد انتهت جلستك. يرجى تحديث الصفحة والمحاولة مرة أخرى"
|
||||
|
||||
[settings]
|
||||
backToSections = "كل الإعدادات"
|
||||
close = "إغلاق"
|
||||
title = "الإعدادات"
|
||||
|
||||
[settings.ai]
|
||||
documents = "المستندات و RAG"
|
||||
@@ -10635,6 +10758,7 @@ ariaLabel = "Super search"
|
||||
filtersAriaLabel = "Search filters"
|
||||
hint = "Type to search"
|
||||
placeholder = "Search Stirling"
|
||||
placeholderShort = "بحث"
|
||||
showLess = "Show less"
|
||||
showMore = "Show {{count}} more"
|
||||
|
||||
@@ -10831,6 +10955,7 @@ searchPlaceholder = "ابحث عن الأدوات..."
|
||||
|
||||
[toolPicker.subcategories]
|
||||
advancedFormatting = "تنسيق متقدم"
|
||||
ai = "AI"
|
||||
automation = "أتمتة"
|
||||
developerTools = "أدوات المطوّر"
|
||||
documentReview = "مراجعة المستند"
|
||||
@@ -11335,8 +11460,11 @@ columnDefault = "العمود {{index}}"
|
||||
convertToPdf = "التحويل إلى PDF"
|
||||
csvStats = "{{rows}} صفوف · {{columns}} أعمدة · {{size}}"
|
||||
emptyFile = "ملف فارغ"
|
||||
htmlHidePreview = "إخفاء المعاينة"
|
||||
htmlPreview = "معاينة HTML"
|
||||
htmlPreviewMobileHidden = "تم تصميم صفحات HTML لعرض سطح المكتب، لذلك تكون المعاينة معطلة افتراضيًا هنا."
|
||||
htmlPreviewWarning = "معاينة HTML — قد لا يتم تحميل الموارد الخارجية · {{size}}"
|
||||
htmlShowPreview = "إظهار المعاينة على أي حال"
|
||||
invalidJson = "JSON غير صالح — عرض المحتوى الخام"
|
||||
lineNumbers = "أرقام الأسطر"
|
||||
loading = "جارٍ التحميل..."
|
||||
@@ -11683,6 +11811,7 @@ exportAll = "تصدير PDF"
|
||||
exportSelected = "تصدير الصفحات المحددة"
|
||||
formFill = "تعبئة النموذج"
|
||||
hideToolbar = "Hide toolbar"
|
||||
moreActions = "مزيد من الإجراءات"
|
||||
multiTool = "الأداة المتعددة"
|
||||
panMode = "وضع التحريك"
|
||||
print = "طباعة PDF"
|
||||
@@ -11703,6 +11832,8 @@ selectAll = "تحديد الكل"
|
||||
selectByNumber = "تحديد حسب أرقام الصفحات"
|
||||
selectLanguage = "اختر اللغة"
|
||||
share = "مشاركة"
|
||||
showAllTools = "إظهار كل الأدوات"
|
||||
showFewerTools = "طي شريط الأدوات"
|
||||
showToolbar = "Show toolbar"
|
||||
toggleAnnotations = "تبديل ظهور الشروح"
|
||||
toggleAttachments = "تبديل عرض المرفقات"
|
||||
|
||||
@@ -41,7 +41,7 @@ false = "Yanlış"
|
||||
fileNotSavedToDisk = "Diskə yadda saxlanılmadı"
|
||||
fileSavedToDisk = "Fayl diskə yadda saxlanıldı"
|
||||
fileSelected = "Seçilən: {{filename}}"
|
||||
filesSelected = "seçilmiş fayllar"
|
||||
filesSelected = "{{count}} fayl"
|
||||
font = "Şrift"
|
||||
fontSizeTooltip = "Səhifə nömrəsi mətninin ölçüsü (pt). Daha böyük rəqəmlər daha böyük mətn yaradır."
|
||||
fontTypeTooltip = "Səhifə nömrələri üçün şrift ailəsi. Sənəd üslubunuza uyğun seçin."
|
||||
@@ -3969,6 +3969,7 @@ back = "Geri"
|
||||
backToFolder = "{{folder}} qovluğuna geri"
|
||||
backToMyFiles = "Mənim Fayllarıma geri"
|
||||
breadcrumbs = "Qovluq yolu"
|
||||
bulkActions = "Əməliyyatlar"
|
||||
cancel = "Ləğv et"
|
||||
classification = "Təsnifat"
|
||||
clearSelection = "Seçimi təmizlə"
|
||||
@@ -4142,6 +4143,11 @@ totalSize = "Ümumi ölçü"
|
||||
type = "Növ"
|
||||
versionHistory = "Versiya yolu"
|
||||
|
||||
[filesPage.filters]
|
||||
activeCount = "{{count}} filtr aktivdir"
|
||||
clearAll = "Filtrləri təmizlə"
|
||||
label = "Filtrlər"
|
||||
|
||||
[filesPage.folderName]
|
||||
cancel = "Ləğv et"
|
||||
error = "Qovluğu saxlamaq mümkün olmadı. Yenidən cəhd edin."
|
||||
@@ -4176,6 +4182,7 @@ label = "Filter files by name"
|
||||
placeholder = "Filter files…"
|
||||
|
||||
[filesPage.sort]
|
||||
label = "Faylları sırala"
|
||||
modifiedAsc = "Ən köhnə əvvəlcə"
|
||||
modifiedDesc = "Ən yenilər əvvəlcə"
|
||||
nameAsc = "Ad A→Z"
|
||||
@@ -4305,11 +4312,14 @@ issues = "GitHub"
|
||||
[formFill]
|
||||
allSaved = "Hamısı saxlanıldı"
|
||||
analyzingFields = "Analysing form fields..."
|
||||
applyFailed = "Dəyişiklikləri tətbiq etmək mümkün olmadı"
|
||||
extractCsvError = "CSV çıxarmaq alınmadı"
|
||||
extractXlsxError = "XLSX çıxarmaq alınmadı"
|
||||
filled = "filled"
|
||||
flattenAfterFilling = "Doldurduqdan sonra yastılaşdır"
|
||||
goToPage = "Bu səhifəyə keç"
|
||||
noFields = "No fillable form fields found in this PDF."
|
||||
page = "Səhifə"
|
||||
placeholderEnter = "Enter"
|
||||
placeholderSelect = "Select"
|
||||
requiredAbbreviation = "təl."
|
||||
@@ -4318,8 +4328,83 @@ rescanFields = "Sahələri yenidən skan et"
|
||||
rescanFormFields = "Forma sahələrini yenidən skan et"
|
||||
save = "Saxla"
|
||||
saveShortcut = "Saxlamaq üçün Ctrl+S"
|
||||
skippedEdits_one = "1 dəyişiklik tətbiq edilə bilmədi:"
|
||||
skippedEdits_other = "{{count}} dəyişiklik tətbiq edilə bilmədi:"
|
||||
skippedEditsTruncated = "{{count}} əlavə qeyd göstərilmir."
|
||||
unsavedChanges = "Saxlanılmamış dəyişikliklər"
|
||||
|
||||
[formFill.create]
|
||||
commit = "PDF-ə {{count}} sahə əlavə et"
|
||||
empty = "Hələ heç bir sahə çəkilməyib."
|
||||
failed = "Sahələri əlavə etmək alınmadı"
|
||||
goToField = "Bu sahəyə keç"
|
||||
hint = "Sahə növünü seçin, sonra onu səhifədə çəkin."
|
||||
placing = "Səhifədə {{type}} sahəsi çəkin. Dayandırmaq üçün Esc düyməsini basın."
|
||||
preview = "Önizləmə üçün basılı saxlayın"
|
||||
previewHelp = "Sahələrin əlavə edildikdən sonra necə görünəcəyini redaktə konturları olmadan görmək üçün basılı saxlayın."
|
||||
removeField = "Sahəni sil"
|
||||
|
||||
[formFill.editor]
|
||||
action = "Düymə əməliyyatı"
|
||||
actionHelp = "Düyməyə kliklənəndə nə baş verəcəyi."
|
||||
actionNone = "Heç nə"
|
||||
actionPrint = "Çap et"
|
||||
actionReset = "Formanı sıfırla"
|
||||
actionSubmit = "URL-ə göndər"
|
||||
actionUri = "URL aç"
|
||||
actionUrl = "URL"
|
||||
actionUrlHelp = "Düymənin açdığı və ya göndərdiyi ünvan."
|
||||
addOption = "Seçim əlavə et"
|
||||
caption = "Düymə başlığı"
|
||||
captionHelp = "Düymənin üzərində göstərilən mətn."
|
||||
defaultValue = "Standart dəyər"
|
||||
defaultValueHelp = "Kimsə doldurmazdan əvvəl sahədə olan məzmun. Boş sahə üçün boş buraxın."
|
||||
fontSize = "Şrift ölçüsü"
|
||||
fontSizeHelp = "Sahə daxilində mətn ölçüsü. Oxuyucunun uyğunlaşdırmasına imkan vermək üçün boş buraxın."
|
||||
label = "Etiket"
|
||||
labelHelp = "Formanı dolduran şəxsə göstərilən mətn. Sahə adına qayıtmaq üçün boş buraxın."
|
||||
maxLength = "Maks. uzunluq (comb)"
|
||||
maxLengthHelp = "Neçə simvolun sığacağını məhdudlaşdırır, bərabər aralıqlı xanalar kimi çəkilir."
|
||||
multiline = "Çoxsətirli"
|
||||
multilineHelp = "Birdən çox mətn sətrinə icazə verir və sahənin kənarında sətirə keçirir."
|
||||
multiSelect = "Çoxlu seçimə icazə ver"
|
||||
multiSelectHelp = "Eyni anda birdən çox seçimin seçilməsinə imkan verir."
|
||||
name = "Sahə adı"
|
||||
nameHelp = "Sahənin daxili adı. Məlumat ixrac edilərkən və ya forma başqa sistemdən doldurularkən istifadə olunur, buna görə unikal və boşluqsuz saxlayın."
|
||||
optionGap = "Seçim aralığı"
|
||||
optionGapHelp = "Düymələr arasındakı məsafə, punktla. Onları qutuda bərabər yaymaq üçün boş buraxın."
|
||||
optionPlaceholder = "Seçim {{n}}"
|
||||
options = "Seçimlər"
|
||||
optionsEmpty = "Ən azı bir seçim əlavə edin."
|
||||
optionsHelp = "Siyahıda təqdim olunan seçimlər. Hər biri yazıldığı kimi saxlanılır, buna görə qısa və fərqli saxlayın."
|
||||
optionSize = "Seçim ölçüsü"
|
||||
optionSizeHelp = "Hər düymənin eni və hündürlüyü, punktla. Çəkdiyiniz qutuya uyğunlaşdırmaq üçün boş buraxın."
|
||||
readOnly = "Yalnız oxuma"
|
||||
readOnlyHelp = "Dəyəri göstərir, lakin heç kimin onu redaktə etməsinə imkan vermir."
|
||||
removeOption = "Seçimi sil"
|
||||
required = "Məcburi"
|
||||
requiredHelp = "Bu sahə doldurulmayana qədər forma göndərilə bilməz."
|
||||
signatureNote = "Yalnız yer tutucudur - burada imza atmırsınız. Bu, imzanın harada yerləşməli olduğunu göstərir ki, sənəd imzalananda PDF imzalayıcı (Adobe Acrobat, imzalama xidməti və s.) imzanı bu yerə yerləşdirsin."
|
||||
tooltip = "İpucu"
|
||||
tooltipHelp = "Kimsə PDF oxuyucusunda sahənin üzərinə gəldikdə göstərilən ipucu."
|
||||
type = "Növ"
|
||||
typeHelp = "Bu sahənin hansı növdə olduğunu göstərir. Dəyişdirildikdə sahə yenidən qurulur, buna görə cari dəyəri saxlanılmır."
|
||||
|
||||
[formFill.mode]
|
||||
create = "Yarat"
|
||||
fill = "Doldur"
|
||||
label = "Forma redaktoru rejimi"
|
||||
modify = "Dəyişdir"
|
||||
|
||||
[formFill.modify]
|
||||
commit = "{{count}} dəyişikliyi saxla"
|
||||
delete = "Sil"
|
||||
empty = "Bu PDF-də hələ forma sahələri yoxdur."
|
||||
failed = "Dəyişiklikləri saxlamaq alınmadı"
|
||||
groupSizeHint = "Seçim ölçüsündən istifadə edin"
|
||||
hint = "Xüsusiyyətlərini redaktə etmək üçün sahə seçin, onu səhifədə sürükləyin və ya silin."
|
||||
restore = "Bərpa et"
|
||||
|
||||
[formFill.sidebar]
|
||||
close = "Yan paneli bağla"
|
||||
|
||||
@@ -4585,6 +4670,10 @@ desc = "Sənəd məhdudiyyətlərini və icazələrini dəyişin"
|
||||
tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights"
|
||||
title = "İcazələri Dəyişdir"
|
||||
|
||||
[home.classify]
|
||||
desc = "Bunun hansı növ sənəd olduğunu müəyyənləşdirin və etiketləyin."
|
||||
title = "Təsnif et"
|
||||
|
||||
[home.compare]
|
||||
desc = "2 PDF Sənədini müqayisə edir və fərqləri göstərir"
|
||||
tags = "fərq"
|
||||
@@ -5098,6 +5187,30 @@ openProcessor = "Open PDF Processor"
|
||||
count = "{{remaining}} of {{total}}"
|
||||
label = "Free credits"
|
||||
|
||||
[notifications]
|
||||
empty = "Bildiriləcək heç nə yoxdur."
|
||||
handoffUnavailable = "Bu brauzer emalçıya sənədi redaktora ötürməyə imkan verməyəcək. Bunun əvəzinə onu redaktordan açın."
|
||||
noDocumentLinked = "Bu xəta konkret sənədlə əlaqəli deyil, buna görə burada açılacaq heç nə yoxdur."
|
||||
notOnThisDevice = "Bu sənəd bu cihazda deyil, buna görə burada açıla bilməz."
|
||||
occurrences = "{{count}} dəfə"
|
||||
open = "Bildirişlər"
|
||||
title = "Bildirişlər"
|
||||
unread = "Oxunmamış"
|
||||
|
||||
[notifications.action]
|
||||
failed = "Bu alınmadı. Bir azdan yenidən cəhd edin."
|
||||
unavailable = "Bu bildiriş üçün əlçatan deyil."
|
||||
|
||||
[notifications.detail]
|
||||
copied = "Kopyalandı"
|
||||
copy = "Xətanı kopyala"
|
||||
less = "Daha az göstər"
|
||||
more = "Tam mesajı göstər"
|
||||
|
||||
[notifications.section]
|
||||
earlier = "Əvvəlkilər"
|
||||
new = "Yeni"
|
||||
|
||||
[oauth.error]
|
||||
message = "Təsdiqləmə uğurlu olmadı. Bu pəncərəni bağlayıb yenidən cəhd edə bilərsiniz."
|
||||
title = "Təsdiqləmə uğursuz oldu"
|
||||
@@ -6735,8 +6848,8 @@ barAria = "Free PDFs remaining"
|
||||
capSuffix_one = "{{allowance}} pulsuz PDF-dən istifadə olunub"
|
||||
capSuffix_other = "{{allowance}} pulsuz PDF-dən istifadə olunub"
|
||||
eyebrow = "Processor sınağı"
|
||||
statusLabel_one = "{{remaining}} qalıb"
|
||||
statusLabel_other = "{{remaining}} qalıb"
|
||||
statusLabel_one = "{{used}} istifadə olunub"
|
||||
statusLabel_other = "{{used}} istifadə olunub"
|
||||
sub = "PDF Editor-dan pulsuz istifadə edin. PDF-ləri avtomatik emal etmək üçün ödəyin."
|
||||
title_one = "{{allowance}} PDF-i pulsuz emal edin"
|
||||
title_other = "{{allowance}} PDF-i pulsuz emal edin"
|
||||
@@ -7417,6 +7530,8 @@ acknowledge = "Acknowledge"
|
||||
confirm = "Are you sure?"
|
||||
dismiss = "Dismiss"
|
||||
dismissSkipFile = "Skip this file"
|
||||
viewFile = "Fayla bax"
|
||||
viewInProcessor = "Emalçıda bax"
|
||||
|
||||
[portal.failures.debug]
|
||||
copyJson = "Copy JSON"
|
||||
@@ -7428,6 +7543,8 @@ showJson = "Show raw JSON ({{total}})"
|
||||
|
||||
[portal.failures.disabled]
|
||||
closed = "This failure is already closed."
|
||||
noDocument = "Bu xəta konkret sənədə aid qeydə alınmayıb, buna görə burada açılacaq heç nə yoxdur."
|
||||
unattended = "Bu fayl qovluq, bucket və ya webhook tərəfindən ötürülüb, buna görə onu açmaq üçün heç kimin brauzerində saxlanılmır."
|
||||
unavailable = "Not available for this failure."
|
||||
|
||||
[portal.failures.empty]
|
||||
@@ -8716,7 +8833,9 @@ title = "Mənbələr"
|
||||
connectSource = "Mənbə qoş"
|
||||
|
||||
[portal.sources.builder]
|
||||
advanced = "Qabaqcıl"
|
||||
back = "Mənbələrə qayıt"
|
||||
backToSource = "Mənbə qurulumuna qayıt"
|
||||
backToTypes = "Bütün mənbə növləri"
|
||||
cancel = "Ləğv et"
|
||||
chooseHint = "Sənədlərin haradan gələcəyini seçin. Bozlaşdırılmış connector-lar yoldadır."
|
||||
@@ -8726,7 +8845,6 @@ create = "Mənbə yarat"
|
||||
createTitle = "Mənbə qoş"
|
||||
delete = "Sil"
|
||||
editTitle = "Mənbəni redaktə et"
|
||||
enabled = "Aktivləşdirilib"
|
||||
save = "Dəyişiklikləri saxla"
|
||||
|
||||
[portal.sources.builder.folderAccess]
|
||||
@@ -8763,6 +8881,7 @@ consume = "Consume: hər faylı bir dəfə emal et"
|
||||
snapshot = "Snapshot: hər icrada qovluğu yenidən oxu"
|
||||
|
||||
[portal.sources.networkFields.recursive]
|
||||
helperText = "Fayllarınız iç-içə qovluqlarda təşkil olunubsa alt qovluqları daxil edin, ya da yalnız üst səviyyəni izləyin."
|
||||
label = "Qovluq dərinliyi"
|
||||
|
||||
[portal.sources.networkFields.recursive.options]
|
||||
@@ -8816,6 +8935,7 @@ hash = "Ölçü, tarix və məzmun yoxlaması"
|
||||
stat = "Ölçü və dəyişdirilmə tarixi"
|
||||
|
||||
[portal.sources.types.folder.fields.mode]
|
||||
helperText = "Emal edildikdən sonra orijinal faylın yerində saxlanılıb-saxlanılmayacağı. Orijinal fayl yerində saxlanılarsa, pipeline növbəti dəfə mənbəni skan etdikdə yenidən emal olunacaq."
|
||||
label = "Oxuma rejimi"
|
||||
|
||||
[portal.sources.types.folder.fields.mode.options]
|
||||
@@ -8823,6 +8943,7 @@ consume = "İstehlak: hər faylı bir dəfə emal et"
|
||||
snapshot = "Snapshot: hər işə salmada qovluğu yenidən oxu"
|
||||
|
||||
[portal.sources.types.folder.fields.recursive]
|
||||
helperText = "Fayllarınız iç-içə qovluqlarda təşkil olunubsa alt qovluqları daxil edin, ya da yalnız üst səviyyəni izləyin."
|
||||
label = "Qovluq dərinliyi"
|
||||
|
||||
[portal.sources.types.folder.fields.recursive.options]
|
||||
@@ -9696,7 +9817,9 @@ toolNotAvailableLocally = "Sizin Stirling-PDF serveriniz oflayndır və \"{{endp
|
||||
expired = "Sessiyanızın vaxtı bitdi. Səhifəni yeniləyin və yenidən cəhd edin."
|
||||
|
||||
[settings]
|
||||
backToSections = "Bütün ayarlar"
|
||||
close = "Bağla"
|
||||
title = "Ayarlar"
|
||||
|
||||
[settings.ai]
|
||||
documents = "Sənədlər və RAG"
|
||||
@@ -10635,6 +10758,7 @@ ariaLabel = "Super search"
|
||||
filtersAriaLabel = "Search filters"
|
||||
hint = "Type to search"
|
||||
placeholder = "Search Stirling"
|
||||
placeholderShort = "Axtar"
|
||||
showLess = "Show less"
|
||||
showMore = "Show {{count}} more"
|
||||
|
||||
@@ -10831,6 +10955,7 @@ searchPlaceholder = "Alətlərdə axtar..."
|
||||
|
||||
[toolPicker.subcategories]
|
||||
advancedFormatting = "Qabaqcıl Formatlama"
|
||||
ai = "AI"
|
||||
automation = "Avtomatlaşdırma"
|
||||
developerTools = "Tərtibatçı Alətləri"
|
||||
documentReview = "Sənəd Baxışı"
|
||||
@@ -11335,8 +11460,11 @@ columnDefault = "Sütun {{index}}"
|
||||
convertToPdf = "PDF-ə çevir"
|
||||
csvStats = "{{rows}} sətir · {{columns}} sütun · {{size}}"
|
||||
emptyFile = "Boş fayl"
|
||||
htmlHidePreview = "Önizləməni gizlət"
|
||||
htmlPreview = "HTML ön baxış"
|
||||
htmlPreviewMobileHidden = "HTML səhifələri masaüstü enləri üçün tərtib edilib, buna görə burada önizləmə standart olaraq söndürülüb."
|
||||
htmlPreviewWarning = "HTML ön baxış — xarici resurslar yüklənməyə bilər · {{size}}"
|
||||
htmlShowPreview = "Yenə də önizləməni göstər"
|
||||
invalidJson = "Etibarsız JSON — xam məzmun göstərilir"
|
||||
lineNumbers = "Sətir nömrələri"
|
||||
loading = "Yüklənir..."
|
||||
@@ -11683,6 +11811,7 @@ exportAll = "PDF-i ixrac et"
|
||||
exportSelected = "Seçilmiş səhifələri ixrac et"
|
||||
formFill = "Formu doldur"
|
||||
hideToolbar = "Hide toolbar"
|
||||
moreActions = "Daha çox əməliyyat"
|
||||
multiTool = "Çoxfunksiyalı alət"
|
||||
panMode = "Sürüşdürmə rejimi"
|
||||
print = "PDF-i çap et"
|
||||
@@ -11703,6 +11832,8 @@ selectAll = "Hamısını seç"
|
||||
selectByNumber = "Səhifə nömrələrinə görə seç"
|
||||
selectLanguage = "Dili seçin"
|
||||
share = "Paylaş"
|
||||
showAllTools = "Bütün alətləri göstər"
|
||||
showFewerTools = "Alətlər panelini yığ"
|
||||
showToolbar = "Show toolbar"
|
||||
toggleAnnotations = "Annotasiyaların görünməsini dəyiş"
|
||||
toggleAttachments = "Qoşmaları aç/bağla"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user