mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Merge branch 'main' into py_format_ruff_0_16_2
This commit is contained in:
@@ -462,7 +462,10 @@ jobs:
|
||||
});
|
||||
|
||||
cleanup-v2-deployment:
|
||||
environment: pr-preview
|
||||
# Tearing a preview down is not a deployment - no deployment object.
|
||||
environment:
|
||||
name: pr-preview
|
||||
deployment: false
|
||||
if: github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
@@ -9,7 +9,10 @@ permissions:
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
environment: pr-preview
|
||||
# Tearing a preview down is not a deployment - no deployment object.
|
||||
environment:
|
||||
name: pr-preview
|
||||
deployment: false
|
||||
if: github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
@@ -20,7 +20,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
environment: ci-unsigned
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -42,7 +42,9 @@ jobs:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
playwright-e2e-enterprise:
|
||||
environment: ci-unsigned
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
needs: pick
|
||||
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE,
|
||||
# so the suite can't boot premium and would fail. See the header comment.
|
||||
@@ -325,7 +327,9 @@ jobs:
|
||||
# Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml)
|
||||
# and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
|
||||
multinode-e2e:
|
||||
environment: ci-unsigned
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
needs: [pick, playwright-e2e-enterprise]
|
||||
# Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret.
|
||||
if: >-
|
||||
|
||||
@@ -61,7 +61,9 @@ jobs:
|
||||
filters: .github/config/.files.yaml
|
||||
|
||||
gradle-cache-prime:
|
||||
environment: ci-unsigned
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
name: Prime shared Gradle cache
|
||||
needs: [files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -10,7 +10,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
check-licence:
|
||||
environment: ci-unsigned
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
|
||||
@@ -11,7 +11,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
check-generate-openapi-docs:
|
||||
environment: ci-unsigned
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
|
||||
@@ -13,7 +13,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
migration-test:
|
||||
environment: ci-unsigned
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
|
||||
@@ -17,7 +17,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
docker-compose-tests:
|
||||
environment: ci-unsigned
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
|
||||
@@ -11,7 +11,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
playwright-e2e-live:
|
||||
environment: ci-unsigned
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
|
||||
@@ -43,7 +43,9 @@ jobs:
|
||||
|
||||
generate-frontend-license-report:
|
||||
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
|
||||
environment: ci-bot
|
||||
environment:
|
||||
name: ci-bot
|
||||
deployment: false
|
||||
if: needs.files-changed.outputs.licenses-frontend == 'true'
|
||||
name: Generate Frontend License Report
|
||||
needs: files-changed
|
||||
@@ -319,7 +321,9 @@ jobs:
|
||||
|
||||
generate-backend-license-report:
|
||||
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
|
||||
environment: ci-bot
|
||||
environment:
|
||||
name: ci-bot
|
||||
deployment: false
|
||||
if: needs.files-changed.outputs.licenses-backend == 'true'
|
||||
needs: files-changed
|
||||
name: Generate Backend License Report
|
||||
|
||||
@@ -38,7 +38,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
determine-matrix:
|
||||
environment: ci-unsigned
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -116,7 +118,9 @@ jobs:
|
||||
env:
|
||||
INPUT_PLATFORM: ${{ github.event.inputs.platform }}
|
||||
build-jars:
|
||||
environment: ci-unsigned
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
needs: determine-matrix
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
|
||||
@@ -127,7 +127,9 @@ jobs:
|
||||
# Runs the @nightly tag (conversion scenarios) plus a 10-shard concurrency run
|
||||
# of every other feature.
|
||||
cucumber-nightly:
|
||||
environment: ci-unsigned
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
name: Cucumber (nightly scenarios + full concurrency)
|
||||
runs-on: ubuntu-latest
|
||||
# Fork pull requests get no MAVEN_* secrets, so the image build cannot work.
|
||||
|
||||
@@ -63,7 +63,9 @@ jobs:
|
||||
determine-matrix:
|
||||
# Only probes APPLE_CERTIFICATE for presence, so it stays on the unrestricted
|
||||
# signing environment - release-signing would block every PR run.
|
||||
environment: ci-signing
|
||||
environment:
|
||||
name: ci-signing
|
||||
deployment: false
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
|
||||
@@ -37,7 +37,9 @@ jobs:
|
||||
# spring-security=true matrix entry if `task backend:build` and
|
||||
# `task backend:build:ci` produce equivalent JARs (verify before wiring).
|
||||
test-build-docker-images:
|
||||
environment: ci-unsigned
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -27,3 +27,8 @@ app/core/src/main/java/stirling/software/SPDF/pdf/signature/CreateSignatureBase.
|
||||
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
|
||||
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
|
||||
.github/workflows/tauri-build.yml:generic-api-key:402
|
||||
|
||||
# Staging Supabase publishable key (public by design). Ignored here rather than with an
|
||||
# inline gitleaks:allow because a trailing comment in a .properties file is part of the
|
||||
# value, so the pragma would end up inside the key.
|
||||
app/saas/src/main/resources/application-staging.properties:generic-api-key:16
|
||||
|
||||
+49
-6
@@ -57,16 +57,57 @@ tasks:
|
||||
- cmd: ./gradlew clean bootRun -PbuildWithFrontend=true
|
||||
platforms: [linux, darwin]
|
||||
|
||||
# SaaS backend. dev:saas -> the PR's preview branch, staging:saas -> shared v3,
|
||||
# PROFILES=none -> production against your own SAAS_DB_*. Production has no named
|
||||
# task on purpose. Use `none`, not an empty value: Go template `default` treats ""
|
||||
# as absent and would resolve back to dev.
|
||||
|
||||
dev:saas:
|
||||
desc: "Start backend in SaaS flavor against Supabase"
|
||||
# `dotenv:` reads from the root Taskfile's directory (".") because this
|
||||
# subtaskfile is included with `dir: .`.
|
||||
desc: "Start SaaS backend against the current PR's Supabase preview branch"
|
||||
dotenv: ['app/.env.saas.local', 'app/.env.saas']
|
||||
vars:
|
||||
PROFILES: '{{.PROFILES | default "dev"}}'
|
||||
cmds:
|
||||
# Don't move this check into a `sh:` var: dotenv is visible in cmds but not
|
||||
# during var evaluation, so the test would always see an empty value.
|
||||
- cmd: |
|
||||
if [ "{{.PROFILES}}" = "dev" ] && [ -z "${SAAS_DEV_PROJECT_REF:-}" ]; then
|
||||
echo ">> SAAS_DEV_PROJECT_REF is not set."
|
||||
echo ">> Testing a SaaS PR? Put its ref, DB password and publishable key in app/.env.saas.local."
|
||||
echo ">> Wanted the shared v3 project? Use 'task backend:staging:saas' instead."
|
||||
exit 1
|
||||
fi
|
||||
- task: _run:saas
|
||||
vars:
|
||||
PORT: '{{.PORT}}'
|
||||
PROFILES: '{{.PROFILES}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
|
||||
staging:saas:
|
||||
desc: "Start SaaS backend against the shared v3 staging project"
|
||||
cmds:
|
||||
- task: _run:saas
|
||||
vars:
|
||||
PORT: '{{.PORT}}'
|
||||
PROFILES: staging
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
|
||||
_run:saas:
|
||||
internal: true
|
||||
dotenv: ['app/.env.saas.local', 'app/.env.saas']
|
||||
ignore_error: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
# Override to "" to run the pure `saas` profile against your own SAAS_DB_*.
|
||||
PROFILES: '{{.PROFILES | default "dev"}}'
|
||||
# Built here rather than inline in the cmds below: the Windows line is an
|
||||
# unquoted YAML scalar wrapping a cmd.exe string, so a nested {{if ne .X
|
||||
# "none"}} needs escaped quotes that reach the Go template as literal
|
||||
# backslashes and fail with `unexpected "\" in operand`.
|
||||
PROFILE_ARGS: '{{if ne .PROFILES "none"}}--spring.profiles.include={{.PROFILES}}{{end}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
@@ -77,9 +118,11 @@ tasks:
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
cmds:
|
||||
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}"
|
||||
# PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile
|
||||
# against SAAS_DB_* (production).
|
||||
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args=\"{{.PROFILE_ARGS}}\"{{end}}"
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILES}}--args='--spring.profiles.include={{.PROFILES}}'{{end}}
|
||||
- cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args='{{.PROFILE_ARGS}}'{{end}}
|
||||
platforms: [linux, darwin]
|
||||
|
||||
build:
|
||||
|
||||
+64
-10
@@ -5,6 +5,14 @@ version: '3'
|
||||
# mode flag) or use `--project editor/...` for tsc — so the editor lives
|
||||
# under frontend/editor/ without each task needing a cd.
|
||||
|
||||
vars:
|
||||
# Dev-only browser-tab label so concurrent worktrees are distinguishable. Only
|
||||
# the worktree folder basename (e.g. "wt1") is exposed — never the full path,
|
||||
# hostname, or user. Dropped from production builds.
|
||||
DEV_LABEL:
|
||||
sh: >-
|
||||
{{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
|
||||
|
||||
tasks:
|
||||
install:
|
||||
desc: "Install dependencies"
|
||||
@@ -80,16 +88,52 @@ tasks:
|
||||
OPEN: '{{.OPEN | default ""}}'
|
||||
env:
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
# Dev-only browser-tab label so concurrent worktrees are distinguishable.
|
||||
# Only the worktree folder basename (e.g. "wt1") is exposed — never the
|
||||
# full path, hostname, or user. Consumed at dev-serve time by vite.config
|
||||
# and dropped from production builds.
|
||||
STIRLING_DEV_LABEL:
|
||||
sh: >-
|
||||
{{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
|
||||
STIRLING_DEV_LABEL: '{{.DEV_LABEL}}'
|
||||
cmds:
|
||||
- npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
|
||||
|
||||
# Separate from dev:_run rather than a flag on it: Task sets an `env:` key even
|
||||
# when its value resolves to empty, and Vite treats an empty process.env VITE_* as
|
||||
# authoritative over the committed editor/.env, so folding these in blanks Supabase
|
||||
# config for the core, proprietary and desktop dev servers.
|
||||
dev:_run:saas:
|
||||
internal: true
|
||||
ignore_error: true
|
||||
# The backend's own env files, so both halves target one project. Paths are
|
||||
# relative to this taskfile's dir, `frontend`.
|
||||
dotenv: ['../app/.env.saas.local', '../app/.env.saas']
|
||||
vars:
|
||||
PORT: '{{.PORT | default "5173"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
OPEN: '{{.OPEN | default ""}}'
|
||||
SAAS_ENV: '{{.SAAS_ENV | default "dev"}}'
|
||||
env:
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
STIRLING_DEV_LABEL: '{{.DEV_LABEL}}'
|
||||
SAAS_ENV: '{{.SAAS_ENV}}'
|
||||
# A real process.env VITE_* beats a committed .env in Vite (loadEnv applies
|
||||
# process.env last), which is what lets this override editor/.env.
|
||||
#
|
||||
# These must stay `sh:`, not Go templates: dotenv values are visible to Task's
|
||||
# embedded shell but not to templates, where {{.SAAS_DEV_PROJECT_REF}} is
|
||||
# always empty.
|
||||
VITE_SUPABASE_URL:
|
||||
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}" ;;
|
||||
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}" ;;
|
||||
esac
|
||||
cmds:
|
||||
- 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"'
|
||||
- npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}}
|
||||
|
||||
dev:
|
||||
desc: "Start frontend dev server"
|
||||
cmds:
|
||||
@@ -111,13 +155,23 @@ tasks:
|
||||
vars: { MODE: proprietary, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
|
||||
|
||||
dev:saas:
|
||||
desc: "Start frontend dev server in SaaS mode"
|
||||
desc: "Start frontend dev server in SaaS mode (SAAS_ENV=dev|staging|prod)"
|
||||
deps:
|
||||
- task: prepare
|
||||
vars: { MODE: saas }
|
||||
vars:
|
||||
SAAS_ENV: '{{.SAAS_ENV | default "dev"}}'
|
||||
# prod routes to the plain runner, which sets no VITE_SUPABASE_* and so leaves
|
||||
# the committed editor/.env alone.
|
||||
RUNNER: '{{if eq .SAAS_ENV "prod"}}dev:_run{{else}}dev:_run:saas{{end}}'
|
||||
cmds:
|
||||
- task: dev:_run
|
||||
vars: { MODE: saas, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
|
||||
- task: '{{.RUNNER}}'
|
||||
vars:
|
||||
MODE: saas
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
OPEN: '{{.OPEN}}'
|
||||
SAAS_ENV: '{{.SAAS_ENV}}'
|
||||
|
||||
dev:desktop:
|
||||
desc: "Start frontend dev server in desktop mode"
|
||||
|
||||
+18
-3
@@ -99,11 +99,22 @@ tasks:
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
|
||||
# Set SAAS_DEV_PROJECT_REF in app/.env.saas.local to pick the PR.
|
||||
dev:saas:
|
||||
desc: "Start SaaS backend + frontend concurrently on free ports"
|
||||
desc: "Start SaaS backend + frontend + engine against the current PR's preview branch"
|
||||
cmds:
|
||||
- task: dev:_all
|
||||
vars: { FRONTEND: saas, BACKEND: saas }
|
||||
vars: { FRONTEND: saas, BACKEND: saas, SAAS_ENV: dev }
|
||||
|
||||
staging:saas:
|
||||
desc: "Start SaaS backend + frontend + engine against the shared v3 staging project"
|
||||
cmds:
|
||||
- task: dev:_all
|
||||
vars:
|
||||
FRONTEND: saas
|
||||
BACKEND: saas
|
||||
BACKEND_TASK: backend:staging:saas
|
||||
SAAS_ENV: staging
|
||||
|
||||
dev:all:
|
||||
desc: "Start backend + frontend + engine concurrently on free ports"
|
||||
@@ -115,6 +126,9 @@ tasks:
|
||||
vars:
|
||||
FRONTEND: '{{.FRONTEND | default "proprietary"}}'
|
||||
BACKEND: '{{.BACKEND | default "proprietary"}}'
|
||||
BACKEND_TASK: '{{.BACKEND_TASK | default (printf "backend:dev:%s" .BACKEND)}}'
|
||||
# Only meaningful to the saas frontend; every other flavor ignores it.
|
||||
SAAS_ENV: '{{.SAAS_ENV | default ""}}'
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
@@ -124,7 +138,7 @@ tasks:
|
||||
- task: engine:dev
|
||||
vars:
|
||||
PORT: '{{.ENGINE_PORT}}'
|
||||
- task: 'backend:dev:{{.BACKEND}}'
|
||||
- task: '{{.BACKEND_TASK}}'
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
|
||||
@@ -134,6 +148,7 @@ tasks:
|
||||
PORT: '{{.FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
SAAS_ENV: '{{.SAAS_ENV}}'
|
||||
|
||||
# ============================================================
|
||||
# Build
|
||||
|
||||
+35
-17
@@ -1,15 +1,16 @@
|
||||
###############################################################################
|
||||
# Stirling-PDF SaaS environment defaults.
|
||||
# Stirling-PDF SaaS environment defaults. Committed, non-secret. Real values for secrets go in
|
||||
# .env.saas.local, which is loaded first and wins. Do not commit that file.
|
||||
#
|
||||
# This file is committed and provides non-secret defaults loaded by
|
||||
# `task backend:dev:saas`. Put real values for secrets (passwords, project
|
||||
# refs, edge function secrets) in `.env.saas.local` - any variable set there
|
||||
# takes precedence over what's defined here.
|
||||
# Three environments, each deriving its Supabase URLs, JWT issuer and JWKS from one project ref:
|
||||
#
|
||||
# DO NOT commit `.env.saas.local`. Only `.env.saas` is checked in.
|
||||
###############################################################################
|
||||
# prod PROFILES=none SAAS_DB_* the live project
|
||||
# staging PROFILES=staging SAAS_STAGING_* pinned to v3, always there
|
||||
# dev PROFILES=dev SAAS_DEV_* follows a SaaS PR's preview branch
|
||||
#
|
||||
# dev is the default for `task backend:dev:saas`. Use staging for somewhere stable; use dev when
|
||||
# testing an open SaaS PR, since its preview branch is the only place those migrations are applied.
|
||||
|
||||
# ---------- Supabase project ----------
|
||||
# ---------- Supabase project (prod / no-profile) ----------
|
||||
# Project reference (the subdomain part of <ref>.supabase.co). Required.
|
||||
# Set in .env.saas.local.
|
||||
SAAS_DB_PROJECT_REF=
|
||||
@@ -17,18 +18,35 @@ SAAS_DB_PROJECT_REF=
|
||||
# Edge function secret used by billing/license rollup calls. Set in .env.saas.local.
|
||||
SUPABASE_EDGE_FUNCTION_SECRET=
|
||||
|
||||
# ---------- Database (saas profile) ----------
|
||||
# Direct JDBC URL to the Supabase Postgres. Required when running the plain
|
||||
# `saas` profile (i.e. without `--spring.profiles.include=dev`).
|
||||
# ---------- Database (no profile) ----------
|
||||
# Direct JDBC URL to the Supabase Postgres. Required when running without
|
||||
# `--spring.profiles.include=...`.
|
||||
# Example: jdbc:postgresql://db.<project-ref>.supabase.co:5432/postgres
|
||||
SAAS_DB_URL=
|
||||
SAAS_DB_USERNAME=postgres
|
||||
SAAS_DB_PASSWORD=
|
||||
|
||||
# ---------- Database (dev profile overrides) ----------
|
||||
# Used when `--spring.profiles.include=dev` is active. The dev profile
|
||||
# defaults the URL/username to the shared dev Supabase project, but the
|
||||
# password must still be provided in .env.saas.local.
|
||||
SAAS_DEV_DB_URL=
|
||||
# ---------- staging profile ----------
|
||||
# The shared long-lived v3 project. application-staging.properties defaults the ref,
|
||||
# URL, database host and meter endpoint, so staging needs only the password, in
|
||||
# .env.saas.local. Set SAAS_STAGING_PROJECT_REF to repoint it; everything derives.
|
||||
#
|
||||
# The ref and publishable key are duplicated here because the task derives the
|
||||
# frontend's VITE_SUPABASE_* from them and a shell cannot read a Spring default.
|
||||
# Neither is secret: the ref is a public subdomain, the key ships in the bundle.
|
||||
SAAS_STAGING_PROJECT_REF=qacaivhsjtftfwtgjvva
|
||||
SAAS_STAGING_PUBLISHABLE_KEY=sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY # gitleaks:allow
|
||||
SAAS_STAGING_DB_USERNAME=postgres
|
||||
SAAS_STAGING_DB_PASSWORD=
|
||||
|
||||
# ---------- dev profile ----------
|
||||
# The SaaS PR's Supabase preview branch. Take the ref from that PR's "Supabase
|
||||
# Preview" check; the profile derives URL, JWT issuer, JWKS, meter endpoint and
|
||||
# database host from it, so this one value follows a different PR.
|
||||
#
|
||||
# A preview branch has its own password and keys; the parent project's will not
|
||||
# authenticate. Both go in .env.saas.local, along with the ref.
|
||||
SAAS_DEV_PROJECT_REF=
|
||||
SAAS_DEV_PUBLISHABLE_KEY=
|
||||
SAAS_DEV_DB_USERNAME=postgres
|
||||
SAAS_DEV_DB_PASSWORD=
|
||||
|
||||
+5
@@ -26,6 +26,11 @@ public class AdminPolicyManagementAuthority implements PolicyManagementAuthority
|
||||
return userService.isCurrentUserAdmin();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canTriggerPolicies() {
|
||||
return userService.isCurrentUserAdmin();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long currentUserTeamId() {
|
||||
String username = userService.getCurrentUsername();
|
||||
|
||||
+11
@@ -12,6 +12,17 @@ public interface PolicyManagementAuthority {
|
||||
/** Whether the current user may create, edit, or delete policies (for their own team). */
|
||||
boolean canEditPolicies();
|
||||
|
||||
/**
|
||||
* Whether the current user may run a policy against its <em>configured sources</em> (the manual
|
||||
* "run now" sweep). Kept separate from {@link #canEditPolicies()} because the two are distinct
|
||||
* capabilities, even where a deployment grants both to the same people: a sweep operates on the
|
||||
* team's configured sources using the server's stored connection credentials, which makes it a
|
||||
* policy-management capability rather than ordinary use. Running a policy over the caller's
|
||||
* <em>own</em> uploaded files is not covered by this and stays open to every team member — that
|
||||
* is ordinary editor enforcement.
|
||||
*/
|
||||
boolean canTriggerPolicies();
|
||||
|
||||
/**
|
||||
* The team that scopes the current user's policies — the team a new policy is stamped with and
|
||||
* the only team whose policies the user may see/run/edit. {@code null} when it can't be
|
||||
|
||||
+26
-4
@@ -432,9 +432,10 @@ public class PolicyController {
|
||||
* admin gets no say on SaaS. Team scoping (which team's policies) is enforced separately by
|
||||
* {@link PolicyAccessGuard}. Every mutation routes through {@link #savePolicy} (pause/resume
|
||||
* re-save with a flipped {@code enabled} flag) or {@link #deletePolicy}, so gating those two
|
||||
* covers them all; runs ({@code /run}) stay open to the team. Single-user deployments (login
|
||||
* disabled) have no such role, so they trust the local operator. The path allowlist for folder
|
||||
* sources/outputs is enforced separately by {@link PolicyValidator} at validation time.
|
||||
* covers them all; runs over the caller's own files ({@code /{id}/run}) stay open to the team,
|
||||
* while source sweeps are gated by {@link #requirePolicySweepAllowed}. Single-user deployments
|
||||
* (login disabled) have no such role, so they trust the local operator. The path allowlist for
|
||||
* folder sources/outputs is enforced separately by {@link PolicyValidator} at validation time.
|
||||
*/
|
||||
private void requirePolicyEditingAllowed() {
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
@@ -447,6 +448,25 @@ public class PolicyController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweeping a policy's configured sources requires the same role as managing policies: the sweep
|
||||
* operates on the team's configured sources using the server's stored connection credentials,
|
||||
* which makes it a policy-management capability rather than ordinary use, and team scoping on
|
||||
* its own does not express that. Deliberately narrower than it looks: it gates only the sweep,
|
||||
* not {@link #runStoredPolicy}, because running a policy over documents the caller supplied is
|
||||
* ordinary editor enforcement that every member performs on upload and export.
|
||||
*/
|
||||
private void requirePolicySweepAllowed() {
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return;
|
||||
}
|
||||
if (!policyManagementAuthority.canTriggerPolicies()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Not permitted to run this policy against its configured sources");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "List policies",
|
||||
@@ -571,8 +591,10 @@ public class PolicyController {
|
||||
+ " the enabled flag (which only gates automatic triggering). Returns"
|
||||
+ " the ids of the runs started (poll the run-status endpoint for each)"
|
||||
+ " plus what the sweep skipped - already-processed, parked-by-failure,"
|
||||
+ " and in-flight counts - so an empty result explains itself.")
|
||||
+ " and in-flight counts - so an empty result explains itself. Requires"
|
||||
+ " the policy-management role.")
|
||||
public ResponseEntity<SweepOutcome> trigger(@PathVariable String policyId) {
|
||||
requirePolicySweepAllowed();
|
||||
Policy policy =
|
||||
policyStore
|
||||
.get(policyId)
|
||||
|
||||
+12
@@ -39,6 +39,18 @@ class AdminPolicyManagementAuthorityTest {
|
||||
assertFalse(authority().canEditPolicies());
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminMayTriggerPolicies() {
|
||||
when(userService.isCurrentUserAdmin()).thenReturn(true);
|
||||
assertTrue(authority().canTriggerPolicies());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAdminMayNotTriggerPolicies() {
|
||||
when(userService.isCurrentUserAdmin()).thenReturn(false);
|
||||
assertFalse(authority().canTriggerPolicies());
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentUserTeamIdResolvesFromTheCurrentUsersTeam() {
|
||||
Team team = new Team();
|
||||
|
||||
+74
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.controller;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -738,5 +739,78 @@ class PolicyControllerTest {
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("trigger is forbidden for a team member who cannot manage policies")
|
||||
void triggerForbiddenForMember() {
|
||||
// Sweeping a policy's configured sources is a policy-management capability, so being
|
||||
// in the policy's team is not on its own enough to perform it.
|
||||
applicationProperties.getSecurity().setEnableLogin(true);
|
||||
when(policyManagementAuthority.canTriggerPolicies()).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> controller.trigger("a"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.FORBIDDEN));
|
||||
// Rejected before the policy is looked up, so no run starts.
|
||||
verify(policyRunner, never()).run(any());
|
||||
verify(policyStore, never()).get(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("trigger runs for a caller who may manage policies")
|
||||
void triggerAllowedForLeader() {
|
||||
applicationProperties.getSecurity().setEnableLogin(true);
|
||||
when(policyManagementAuthority.canTriggerPolicies()).thenReturn(true);
|
||||
Policy p = policy("a", 1L);
|
||||
when(policyStore.get("a")).thenReturn(Optional.of(p));
|
||||
when(policyAccessGuard.canAccess(p)).thenReturn(true);
|
||||
SweepOutcome outcome = new SweepOutcome(List.of("run-a"), 1, 0, 0, 0);
|
||||
when(policyRunner.run(p)).thenReturn(outcome);
|
||||
|
||||
ResponseEntity<SweepOutcome> response = controller.trigger("a");
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
|
||||
assertThat(response.getBody()).isEqualTo(outcome);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("trigger skips the role check when login is disabled")
|
||||
void triggerTrustsTheLocalOperator() {
|
||||
// Single-user deployments have no roles at all; the gate must not lock them out of
|
||||
// their
|
||||
// own sweeps.
|
||||
applicationProperties.getSecurity().setEnableLogin(false);
|
||||
Policy p = policy("a", null);
|
||||
when(policyStore.get("a")).thenReturn(Optional.of(p));
|
||||
when(policyAccessGuard.canAccess(p)).thenReturn(true);
|
||||
SweepOutcome outcome = new SweepOutcome(List.of("run-a"), 1, 0, 0, 0);
|
||||
when(policyRunner.run(p)).thenReturn(outcome);
|
||||
|
||||
assertThat(controller.trigger("a").getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
|
||||
verify(policyManagementAuthority, never()).canTriggerPolicies();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("running a policy over the caller's own files stays open to any member")
|
||||
void storedRunIsNotGatedByRole() {
|
||||
// Editor enforcement: every member's upload/export runs the team's stored policies on
|
||||
// their own documents. Gating this the way the sweep is gated would break the editor.
|
||||
applicationProperties.getSecurity().setEnableLogin(true);
|
||||
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)))
|
||||
.thenReturn(handle("run-9"));
|
||||
|
||||
ResponseEntity<JobResponse<Void>> response =
|
||||
assertDoesNotThrow(() -> controller.runStoredPolicy("a", new PolicyRunFiles()));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
|
||||
verify(policyManagementAuthority, never()).canTriggerPolicies();
|
||||
verify(policyManagementAuthority, never()).canEditPolicies();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/** Logs which Supabase project this backend is talking to, and its schema policy. */
|
||||
@Slf4j
|
||||
@Component
|
||||
@Profile({"dev", "staging"})
|
||||
public class SaasProjectNotice {
|
||||
|
||||
private final Environment environment;
|
||||
private final String projectRef;
|
||||
private final String ddlAuto;
|
||||
|
||||
public SaasProjectNotice(
|
||||
Environment environment,
|
||||
@Value("${app.supabase.project-ref:unknown}") String projectRef,
|
||||
@Value("${spring.jpa.hibernate.ddl-auto:none}") String ddlAuto) {
|
||||
this.environment = environment;
|
||||
this.projectRef = projectRef;
|
||||
this.ddlAuto = ddlAuto;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void announceProject() {
|
||||
boolean staging = environment.matchesProfiles("staging");
|
||||
if (staging) {
|
||||
log.info(
|
||||
"""
|
||||
SaaS staging profile: Supabase project {}, ddl-auto={}. This is the SHARED \
|
||||
long-lived environment, so its data and schema are not yours alone. Testing an \
|
||||
open SaaS PR? Use that PR's preview branch instead \
|
||||
(SAAS_DEV_PROJECT_REF in app/.env.saas.local); staging will not have its \
|
||||
migrations.\
|
||||
""",
|
||||
projectRef,
|
||||
ddlAuto);
|
||||
return;
|
||||
}
|
||||
log.info(
|
||||
"SaaS dev profile: Supabase preview branch {}, ddl-auto={}. Disposable, so Hibernate"
|
||||
+ " is allowed to add the inherited tables the migrations do not create.",
|
||||
projectRef,
|
||||
ddlAuto);
|
||||
}
|
||||
}
|
||||
+5
@@ -25,6 +25,11 @@ public class TeamLeaderPolicyManagementAuthority implements PolicyManagementAuth
|
||||
return teamSecurity.isCurrentUserTeamLeader();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canTriggerPolicies() {
|
||||
return teamSecurity.isCurrentUserTeamLeader();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long currentUserTeamId() {
|
||||
return teamSecurity.currentUserTeamId();
|
||||
|
||||
@@ -1,32 +1,40 @@
|
||||
# SaaS dev profile. Points at the dev Supabase project.
|
||||
# Boot: java -jar stirling-pdf.jar --spring.profiles.include=dev
|
||||
# SaaS dev profile: follows the Supabase preview branch of the SaaS PR under test.
|
||||
# One variable switches PR, SAAS_DEV_PROJECT_REF; everything else derives from it.
|
||||
# Want a stable shared environment instead? Use the staging profile.
|
||||
|
||||
spring.config.import=optional:classpath:application-dev-local.properties
|
||||
|
||||
app.supabase.project-ref=qacaivhsjtftfwtgjvva
|
||||
# Let Hibernate reconcile the entity tables so a fresh preview branch heals itself. A branch is built
|
||||
# from the Supabase migrations, which cover the SaaS-owned tables but not the ~28 inherited from the
|
||||
# self-hosted app -- those have only ever been created by ddl-auto. Safe here because a preview branch
|
||||
# is disposable and `update` only ever adds; staging pins `none`, so keep this profile-scoped.
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
|
||||
stirling.supabase.url=https://qacaivhsjtftfwtgjvva.supabase.co
|
||||
stirling.supabase.publishable-key=sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY # gitleaks:allow
|
||||
# From the PR's "Supabase Preview" check. Required with no fallback: ddl-auto=update above must never
|
||||
# be aimed at the shared project.
|
||||
app.supabase.project-ref=${SAAS_DEV_PROJECT_REF}
|
||||
|
||||
spring.datasource.url=${SAAS_DEV_DB_URL:jdbc:postgresql://db.qacaivhsjtftfwtgjvva.supabase.co:5432/postgres?ApplicationName=stirling-consolidation-${user.name}}
|
||||
stirling.supabase.url=https://${app.supabase.project-ref}.supabase.co
|
||||
# Per-branch, not derivable. Dashboard > Settings > API.
|
||||
stirling.supabase.publishable-key=${SAAS_DEV_PUBLISHABLE_KEY}
|
||||
|
||||
# Override the whole URL if the branch needs the pooler host rather than the direct one.
|
||||
spring.datasource.url=${SAAS_DEV_DB_URL:jdbc:postgresql://db.${app.supabase.project-ref}.supabase.co:5432/postgres?ApplicationName=stirling-dev-${user.name}}
|
||||
spring.datasource.username=${SAAS_DEV_DB_USERNAME:postgres}
|
||||
# Password not committed; export SAAS_DEV_DB_PASSWORD or pass --spring.datasource.password=...
|
||||
# A preview branch has its own password; the parent project's will not authenticate.
|
||||
spring.datasource.password=${SAAS_DEV_DB_PASSWORD:}
|
||||
|
||||
# Conservative dev pool sizing.
|
||||
spring.datasource.hikari.maximum-pool-size=2
|
||||
spring.datasource.hikari.minimum-idle=1
|
||||
spring.datasource.hikari.idle-timeout=60000
|
||||
spring.datasource.hikari.max-lifetime=1800000
|
||||
spring.datasource.hikari.keepalive-time=300000
|
||||
spring.datasource.hikari.data-source-properties.ApplicationName=stirling-consolidation-${user.name}
|
||||
spring.datasource.hikari.data-source-properties.ApplicationName=stirling-dev-${user.name}
|
||||
|
||||
logging.level.stirling.software.saas=DEBUG
|
||||
logging.level.org.springframework.security.oauth2.jwt=WARN
|
||||
logging.level.org.springframework.security.oauth2.server.resource=WARN
|
||||
|
||||
# Supabase meter edge fn the Java backend calls (server-to-server, on job close).
|
||||
# URL is not a secret; auth rides the existing SUPABASE_EDGE_FUNCTION_SECRET (same
|
||||
# shared secret the team-invitation flow uses — no service-role key in the Java env).
|
||||
# Blank secret → the meter service no-ops with a WARN, so the app still boots.
|
||||
# The billing portal is NOT here — the FE calls create-customer-portal-session directly.
|
||||
payg.meter.endpoint=https://qacaivhsjtftfwtgjvva.supabase.co/functions/v1/meter-payg-units
|
||||
# Server-to-server meter call. Auth rides SUPABASE_EDGE_FUNCTION_SECRET; blank secret means the meter
|
||||
# service no-ops with a WARN rather than failing the boot.
|
||||
payg.meter.endpoint=https://${app.supabase.project-ref}.supabase.co/functions/v1/meter-payg-units
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# SaaS staging profile: the long-lived shared v3 project, pinned so it is still there tomorrow.
|
||||
# For work on an open SaaS PR use the dev profile, which follows that PR's preview branch.
|
||||
|
||||
spring.config.import=optional:classpath:application-staging-local.properties
|
||||
|
||||
# Stated rather than inherited: application-saas.properties defaults to `update`, and staging's
|
||||
# schema is shared and RLS-dependent, so it must not be reconciled by Hibernate.
|
||||
spring.jpa.hibernate.ddl-auto=none
|
||||
|
||||
# Committed as a default rather than a literal, so staging needs no setup but stays repointable.
|
||||
# Neither the ref nor the publishable key is secret: the ref is a public subdomain, the key ships in
|
||||
# the browser bundle. Everything below derives from the ref, so an override follows through.
|
||||
app.supabase.project-ref=${SAAS_STAGING_PROJECT_REF:qacaivhsjtftfwtgjvva}
|
||||
|
||||
stirling.supabase.url=https://${app.supabase.project-ref}.supabase.co
|
||||
stirling.supabase.publishable-key=${SAAS_STAGING_PUBLISHABLE_KEY:sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY}
|
||||
|
||||
spring.datasource.url=${SAAS_STAGING_DB_URL:jdbc:postgresql://db.${app.supabase.project-ref}.supabase.co:5432/postgres?ApplicationName=stirling-staging-${user.name}}
|
||||
spring.datasource.username=${SAAS_STAGING_DB_USERNAME:postgres}
|
||||
# Password not committed; export SAAS_STAGING_DB_PASSWORD or pass --spring.datasource.password=...
|
||||
spring.datasource.password=${SAAS_STAGING_DB_PASSWORD:}
|
||||
|
||||
# Conservative pool sizing: this is a shared project, so don't hold connections others need.
|
||||
spring.datasource.hikari.maximum-pool-size=2
|
||||
spring.datasource.hikari.minimum-idle=1
|
||||
spring.datasource.hikari.idle-timeout=60000
|
||||
spring.datasource.hikari.max-lifetime=1800000
|
||||
spring.datasource.hikari.keepalive-time=300000
|
||||
spring.datasource.hikari.data-source-properties.ApplicationName=stirling-staging-${user.name}
|
||||
|
||||
logging.level.stirling.software.saas=DEBUG
|
||||
logging.level.org.springframework.security.oauth2.jwt=WARN
|
||||
logging.level.org.springframework.security.oauth2.server.resource=WARN
|
||||
|
||||
# Supabase meter edge fn the Java backend calls (server-to-server, on job close).
|
||||
# URL is not a secret; auth rides the existing SUPABASE_EDGE_FUNCTION_SECRET (same
|
||||
# shared secret the team-invitation flow uses — no service-role key in the Java env).
|
||||
# Blank secret → the meter service no-ops with a WARN, so the app still boots.
|
||||
payg.meter.endpoint=https://${app.supabase.project-ref}.supabase.co/functions/v1/meter-payg-units
|
||||
+12
@@ -32,6 +32,18 @@ class TeamLeaderPolicyManagementAuthorityTest {
|
||||
assertFalse(authority().canEditPolicies());
|
||||
}
|
||||
|
||||
@Test
|
||||
void teamLeaderMayTriggerPolicies() {
|
||||
when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(true);
|
||||
assertTrue(authority().canTriggerPolicies());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonLeaderMayNotTriggerPolicies() {
|
||||
when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(false);
|
||||
assertFalse(authority().canTriggerPolicies());
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentUserTeamIdDelegatesToTeamSecurity() {
|
||||
when(teamSecurity.currentUserTeamId()).thenReturn(9L);
|
||||
|
||||
Reference in New Issue
Block a user