Release 2.58 (#5952)
* bump version * fix formatting locale reset after embedded controller sub-requests (#5944) * fix GHSA-c6w6-57jj-62vh * fix GHSA-m492-gv72-xvxj * fix GHSA-jr9p-4h4j-6c58 * make sure to only use JS logic to call API endpoints * fixes GHSA-r8vr-m544-qh4h * make sure to only use JS logic to call API endpoints * fix GHSA-rw46-qg69-vg6h * fix GHSA-pj8j-p4g4-4vw8 - prevent kimai from rendering images via markdown * fix GHSA-pj8j-p4g4-4vw8 - use a safe network client to prevent SSRF via images * fix GHSA-xv4r-4885-gwpg * fix GHSA-pgcc-vfmc-7cw5 - move GET routes to API with POST method to prevent CSRF * fix tooltip survives page reload * updated wizard images * split wizard and password reset subscriber into two classes * relax upper php limit * added zizmor workflow scans and apply findings * user permissions <name>_other_profile now respect teams * move all linting steps to new job * updated docker image version names * use .env.local for storing APP_SECRET * improve build order and use given tag as ref for checkout, not default main branch * improved APP_SECRET handling, see entrypoint.sh * use local code for building the image for more flexibility, added dockerignore
This commit is contained in:
@@ -83,6 +83,61 @@ function prepareKimai() {
|
||||
echo "Kimai is ready"
|
||||
}
|
||||
|
||||
function ensureAppSecret() {
|
||||
# GHSA-jr9p-4h4j-6c58
|
||||
# Make sure the container never runs with the publicly-known default APP_SECRET.
|
||||
# If the user provided their own value (via -e APP_SECRET=...) it is kept untouched.
|
||||
# Otherwise a unique secret is generated once and persisted below var/data, which
|
||||
# is the directory mounted as a named volume in the documented Docker setup, so it
|
||||
# stays stable across container restarts and re-creations.
|
||||
#
|
||||
# Disable xtrace around all reads/writes of APP_SECRET so the secret never appears
|
||||
# in container logs. The braces around `set +x` keep the disable command itself
|
||||
# from being traced.
|
||||
{ set +x; } 2>/dev/null
|
||||
|
||||
local SECRET_FILE=/opt/kimai/var/data/.appsecret
|
||||
local ENV_LOCAL=/opt/kimai/.env.local
|
||||
|
||||
# Always remove any prior .env.local before deciding which secret applies.
|
||||
# This prevents a stale auto-generated value from lingering after a user
|
||||
# later sets APP_SECRET via docker env / compose. It is regenerated below
|
||||
# in the auto-secret path; in the user-provided path it stays absent so
|
||||
# the real env var remains the single source of truth.
|
||||
rm -f "$ENV_LOCAL"
|
||||
|
||||
if [ -n "$APP_SECRET" ] && [ "$APP_SECRET" != "change_this_to_something_unique" ]; then
|
||||
set -x
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -s "$SECRET_FILE" ]; then
|
||||
APP_SECRET=$(cat "$SECRET_FILE")
|
||||
echo "APP_SECRET: using persisted auto-generated secret"
|
||||
else
|
||||
mkdir -p "$(dirname "$SECRET_FILE")"
|
||||
APP_SECRET=$(php -r 'echo bin2hex(random_bytes(32));')
|
||||
( umask 077 && echo "$APP_SECRET" > "$SECRET_FILE" )
|
||||
chown "$USER_ID:$GROUP_ID" "$SECRET_FILE"
|
||||
echo "APP_SECRET: generated a new unique secret, persisted to var/data volume"
|
||||
fi
|
||||
export APP_SECRET
|
||||
|
||||
# Mirror the resolved secret into .env.local so Symfony's Dotenv picks up
|
||||
# the right value when commands are run via `docker exec` (which does not
|
||||
# inherit the entrypoint's exported env). .env.local is Symfony's official
|
||||
# override file and is loaded before .env. Rewritten on every container
|
||||
# start; the source of truth is the persisted SECRET_FILE above.
|
||||
( umask 077 && echo "APP_SECRET=$APP_SECRET" > "$ENV_LOCAL" )
|
||||
# The PHP runtime (apache/php-fpm) runs as $USER_ID:$GROUP_ID and must be
|
||||
# able to read .env.local; the entrypoint itself runs as root, so the file
|
||||
# would otherwise be 0600 root:root and unreadable to the web user, causing
|
||||
# Symfony's Dotenv to throw PathException at boot.
|
||||
chown "$USER_ID:$GROUP_ID" "$ENV_LOCAL"
|
||||
|
||||
set -x
|
||||
}
|
||||
|
||||
function runServer() {
|
||||
# Just while I'm fixing things
|
||||
/opt/kimai/bin/console kimai:reload --env="$APP_ENV"
|
||||
@@ -98,5 +153,6 @@ function runServer() {
|
||||
|
||||
waitForDB
|
||||
handleStartup
|
||||
ensureAppSecret
|
||||
prepareKimai
|
||||
runServer
|
||||
|
||||
76
.dockerignore
Normal file
76
.dockerignore
Normal file
@@ -0,0 +1,76 @@
|
||||
# Secrets and local environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.dist
|
||||
.dockerhub.secrets
|
||||
config/secrets/*
|
||||
|
||||
# Git and CI
|
||||
.git
|
||||
.gitattributes
|
||||
.gitignore
|
||||
.github
|
||||
|
||||
# Build artifacts and dependencies (installed inside the image)
|
||||
vendor/
|
||||
node_modules/
|
||||
public/bundles/
|
||||
.pnpm-store/
|
||||
.yarn/
|
||||
.yarnrc.yml
|
||||
.pnp.*
|
||||
|
||||
# Symfony runtime data — exclude contents but keep the directories themselves
|
||||
# (the dirs are tracked in git via .gitkeep and code expects them to exist)
|
||||
var/advisories/
|
||||
var/cache/*
|
||||
var/log/*
|
||||
var/sessions/*
|
||||
var/data/*
|
||||
var/dev/*
|
||||
var/invoices/*
|
||||
var/export/*
|
||||
var/packages/*
|
||||
var/plugins/*
|
||||
var/templates/*
|
||||
!var/**/.gitkeep
|
||||
|
||||
# IDE, tools, caches
|
||||
.idea/
|
||||
.claude/
|
||||
.vscode/
|
||||
.php-cs-fixer.cache
|
||||
.php_cs.cache
|
||||
.phpunit.result.cache
|
||||
.phpunit/
|
||||
nbproject/
|
||||
|
||||
# Dev-only configs
|
||||
test/
|
||||
.php-cs-fixer.dist.php
|
||||
php-cs-fixer.sh
|
||||
phpstan.sh
|
||||
phpstan.neon
|
||||
phpstan-baseline.neon
|
||||
eslint.config.mjs
|
||||
.editorconfig
|
||||
.codecov.yml
|
||||
phpunit.xml
|
||||
phpunit.xml.dist
|
||||
|
||||
# Docker and compose (not needed inside the image; .docker/ is copied explicitly)
|
||||
docker-compose.yml
|
||||
docker-compose.*.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# OS junk
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local Symfony overrides
|
||||
public/.user.ini
|
||||
php.ini
|
||||
.php-version
|
||||
config/packages/local.yaml
|
||||
config/bundles-local.php
|
||||
84
.github/workflows/docker.yaml
vendored
84
.github/workflows/docker.yaml
vendored
@@ -9,32 +9,28 @@ on:
|
||||
release:
|
||||
types: [released]
|
||||
|
||||
concurrency:
|
||||
group: docker-build
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Docker images
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{secrets.DOCKERHUB_USERNAME}}
|
||||
password: ${{secrets.DOCKERHUB_PASSWORD}}
|
||||
|
||||
- name: Determine version
|
||||
env:
|
||||
GITHUB_EVENT_INPUTS_KIMAI_TAG: ${{ github.event.inputs.kimai_tag }}
|
||||
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
input="${{ github.event.inputs.kimai_tag }}"
|
||||
input="${GITHUB_EVENT_INPUTS_KIMAI_TAG}"
|
||||
|
||||
# Determine between manual trigger and release event
|
||||
if [ -z "$input" ]; then
|
||||
echo "Using release tag: ${{ github.event.release.tag_name }}"
|
||||
version="${{ github.event.release.tag_name }}"
|
||||
echo "Using release tag: ${GITHUB_EVENT_RELEASE_TAG_NAME}"
|
||||
version="${GITHUB_EVENT_RELEASE_TAG_NAME}"
|
||||
else
|
||||
echo "Using tag provided: $input"
|
||||
version="$input"
|
||||
@@ -47,8 +43,42 @@ jobs:
|
||||
|
||||
echo "kimai_version=$version" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ env.kimai_version }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{secrets.DOCKERHUB_USERNAME}}
|
||||
password: ${{secrets.DOCKERHUB_PASSWORD}}
|
||||
|
||||
# TODO 3.0 remove kimai/kimai2:apache and kimai/kimai2:apache-<version>
|
||||
- name: Apache image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
build-args: |
|
||||
KIMAI=${{ env.kimai_version }}
|
||||
BASE=apache
|
||||
target: prod
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
kimai/kimai2:stable
|
||||
kimai/kimai2:apache
|
||||
kimai/kimai2:2
|
||||
kimai/kimai2:${{ env.kimai_version }}
|
||||
push: true
|
||||
|
||||
# TODO 3.0 remove the deprecated FPM build
|
||||
- name: FPM image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -62,24 +92,8 @@ jobs:
|
||||
kimai/kimai2:fpm
|
||||
push: true
|
||||
|
||||
- name: Apache image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
build-args: |
|
||||
KIMAI=${{ env.kimai_version }}
|
||||
BASE=apache
|
||||
target: prod
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
kimai/kimai2:stable
|
||||
kimai/kimai2:apache
|
||||
kimai/kimai2:apache-${{ env.kimai_version }}
|
||||
push: true
|
||||
|
||||
- name: Development image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
|
||||
15
.github/workflows/frontend.yaml
vendored
15
.github/workflows/frontend.yaml
vendored
@@ -4,25 +4,30 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: Frontend verification
|
||||
steps:
|
||||
|
||||
- name: Clone Kimai
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup PNPM
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
76
.github/workflows/linting.yaml
vendored
Normal file
76
.github/workflows/linting.yaml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
name: Lint PHP
|
||||
|
||||
on:
|
||||
pull_request: null
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
name: Linting (${{ matrix.php }})
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.2'] # use lowest supported PHP version to prevent introducing syntax from newer PHP versions
|
||||
|
||||
steps:
|
||||
|
||||
- name: Clone Kimai
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: pcov
|
||||
extensions: ctype, gd, iconv, intl, mbstring, xml, zip
|
||||
tools: cs2pr, symfony-cli
|
||||
env:
|
||||
fail-fast: true
|
||||
|
||||
- name: Determine composer cache directory
|
||||
id: composer-cache
|
||||
run: echo "composer_cache_directory=$(composer config cache-dir)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: "${{ env.composer_cache_directory }}"
|
||||
key: ${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install
|
||||
|
||||
- name: Validate Composer
|
||||
run: composer validate --strict --no-check-all
|
||||
|
||||
- name: Lint YAML
|
||||
run: APP_ENV=dev bin/console lint:yaml --parse-tags config
|
||||
|
||||
- name: Lint XLIFF
|
||||
run: APP_ENV=dev bin/console lint:xliff translations
|
||||
|
||||
- name: Check codestyles
|
||||
run: vendor/bin/php-cs-fixer fix --dry-run --verbose --config=.php-cs-fixer.dist.php --using-cache=no --show-progress=none --format=checkstyle | cs2pr
|
||||
|
||||
- name: Run PHPStan for application
|
||||
run: vendor/bin/phpstan analyse -c phpstan.neon --no-progress --error-format=checkstyle | cs2pr
|
||||
|
||||
- name: Run PHPStan for tests
|
||||
run: vendor/bin/phpstan analyse -c tests/phpstan.neon --no-progress --error-format=checkstyle | cs2pr
|
||||
|
||||
- name: Lint codebase
|
||||
run: composer linting
|
||||
|
||||
- name: Check for security issues in packages
|
||||
run: symfony security:check
|
||||
12
.github/workflows/lock-threads.yaml
vendored
12
.github/workflows/lock-threads.yaml
vendored
@@ -5,20 +5,22 @@ on:
|
||||
- cron: '17 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: lock-threads
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
action:
|
||||
permissions:
|
||||
issues: write # needed to comment and close the issues
|
||||
runs-on: ubuntu-latest
|
||||
name: Lock inactive issues
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v6
|
||||
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
|
||||
with:
|
||||
process-only: 'issues, prs'
|
||||
process-only: 'issues'
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-inactive-days: '90'
|
||||
issue-comment: >
|
||||
|
||||
16
.github/workflows/lockfiles.yaml
vendored
16
.github/workflows/lockfiles.yaml
vendored
@@ -1,21 +1,27 @@
|
||||
name: Check .lock files
|
||||
on:
|
||||
|
||||
on: # zizmor: ignore[dangerous-triggers] using the target branch workflow prevents adding new trustedAuthors
|
||||
pull_request_target: null
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
lockfiles:
|
||||
permissions:
|
||||
pull-requests: read # read the PR author
|
||||
runs-on: ubuntu-latest
|
||||
name: Verify lock file integrity
|
||||
steps:
|
||||
- name: Prevent file change
|
||||
uses: xalvarez/prevent-file-change-action@v3
|
||||
uses: xalvarez/prevent-file-change-action@dbc67c205c9ed02aa39c497b422c24f364c3e8dd # v3.0.1
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
pattern: .*\.lock$|^\.github\/.*$
|
||||
trustedAuthors: kevinpapst, dependabot
|
||||
trustedAuthors: kevinpapst
|
||||
|
||||
17
.github/workflows/release-drafter.yaml
vendored
17
.github/workflows/release-drafter.yaml
vendored
@@ -5,26 +5,29 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
correct_repository:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest
|
||||
name: Verify repository
|
||||
steps:
|
||||
- name: fail on fork
|
||||
if: github.repository_owner != 'kimai'
|
||||
run: exit 1
|
||||
|
||||
update_release_draft:
|
||||
name: Draft next release
|
||||
permissions:
|
||||
contents: write # for release-drafter/release-drafter to create a github release
|
||||
pull-requests: read
|
||||
contents: write # to create a github release draft
|
||||
pull-requests: read # to identify changes that occurred
|
||||
needs: correct_repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@v7
|
||||
- uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
|
||||
env:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
43
.github/workflows/testing.yaml
vendored
43
.github/workflows/testing.yaml
vendored
@@ -1,15 +1,24 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request: null
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
name: Integration (${{ matrix.php }})
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:latest
|
||||
image: mysql@sha256:c36050afdca850f23cef85703f84c7531a5ae155a11b5ee1c60acb09937c4084 # 8.4.9
|
||||
env:
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: false
|
||||
MYSQL_ROOT_PASSWORD: kimai
|
||||
@@ -17,20 +26,20 @@ jobs:
|
||||
ports:
|
||||
- 3306/tcp
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.2', '8.3', '8.4', '8.5']
|
||||
|
||||
name: Integration (${{ matrix.php }})
|
||||
steps:
|
||||
|
||||
- name: Clone Kimai
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: pcov
|
||||
@@ -47,7 +56,7 @@ jobs:
|
||||
run: echo "composer_cache_directory=$(composer config cache-dir)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: "${{ env.composer_cache_directory }}"
|
||||
key: ${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
|
||||
@@ -55,28 +64,11 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: composer install
|
||||
|
||||
- name: Validate Composer
|
||||
run: composer validate --strict --no-check-all
|
||||
|
||||
- name: Warmup cache
|
||||
run: APP_ENV=dev bin/console kimai:reload -n
|
||||
|
||||
- name: Check codestyles
|
||||
run: vendor/bin/php-cs-fixer fix --dry-run --verbose --config=.php-cs-fixer.dist.php --using-cache=no --show-progress=none --format=checkstyle | cs2pr
|
||||
|
||||
- name: Run PHPStan for application
|
||||
run: vendor/bin/phpstan analyse -c phpstan.neon --no-progress --error-format=checkstyle | cs2pr
|
||||
|
||||
- name: Run PHPStan for tests
|
||||
run: vendor/bin/phpstan analyse -c tests/phpstan.neon --no-progress --error-format=checkstyle | cs2pr
|
||||
|
||||
- name: Lint codebase
|
||||
run: composer linting
|
||||
|
||||
- name: Install LDAP package (for tests)
|
||||
run: composer require laminas/laminas-ldap
|
||||
|
||||
- name: Setup problem matchers (for PHPUnit)
|
||||
# zizmor: ignore[template-injection] coming from the https://github.com/marketplace/actions/setup-php-action
|
||||
run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
|
||||
|
||||
- name: Run quick unit-tests
|
||||
@@ -104,7 +96,7 @@ jobs:
|
||||
|
||||
- name: Upload code coverage
|
||||
if: matrix.php == '8.5'
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./coverage.xml
|
||||
@@ -120,6 +112,3 @@ jobs:
|
||||
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?charset=utf8mb4&serverVersion=8.0.35
|
||||
APP_ENV: dev
|
||||
MAILER_URL: null://localhost
|
||||
|
||||
- name: Check for security issues in packages
|
||||
run: symfony security:check
|
||||
|
||||
16
.github/workflows/website.yaml
vendored
16
.github/workflows/website.yaml
vendored
@@ -1,4 +1,5 @@
|
||||
name: 'Website update'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -8,19 +9,28 @@ on:
|
||||
release:
|
||||
types: [released]
|
||||
|
||||
concurrency:
|
||||
group: update-website
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Trigger version update for website
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Determine Version"
|
||||
env:
|
||||
GITHUB_EVENT_INPUTS_KIMAI_VERSION: ${{ github.event.inputs.kimai_version }}
|
||||
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
input="${{ github.event.inputs.kimai_version }}"
|
||||
input="${GITHUB_EVENT_INPUTS_KIMAI_VERSION}"
|
||||
|
||||
# Determine between manual trigger and release event
|
||||
if [ -z "$input" ]; then
|
||||
echo "No input provided, using release tag"
|
||||
version="${{ github.event.release.tag_name }}"
|
||||
version="${GITHUB_EVENT_RELEASE_TAG_NAME}"
|
||||
else
|
||||
echo "Using input provided: $input"
|
||||
version="$input"
|
||||
@@ -34,7 +44,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Emit repository_dispatch
|
||||
uses: peter-evans/repository-dispatch@v4
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
with:
|
||||
token: ${{ secrets.WEBSITE_ACCESS_TOKEN }}
|
||||
repository: kimai/www.kimai.org
|
||||
|
||||
28
.github/workflows/zizmor.yaml
vendored
Normal file
28
.github/workflows/zizmor.yaml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Actions Security Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
name: Scan workflows
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor 🌈
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
37
Dockerfile
37
Dockerfile
@@ -24,8 +24,9 @@
|
||||
|
||||
# Source base, one of: fpm, apache
|
||||
ARG BASE="fpm"
|
||||
# Kimai branch/tag to run
|
||||
ARG KIMAI="main"
|
||||
# Kimai version label (used for OCI labels and the KIMAI env var inside the image).
|
||||
# The actual source is read from the local build context, not fetched by version.
|
||||
ARG KIMAI="dev"
|
||||
# Timezone for images
|
||||
ARG TIMEZONE="Europe/Berlin"
|
||||
|
||||
@@ -217,20 +218,6 @@ COPY --from=php-ext-intl /usr/local/lib/php/extensions/no-debug-non-zts-20230831
|
||||
# PHP extension opcache
|
||||
COPY --from=php-ext-opcache /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini
|
||||
|
||||
###########################
|
||||
# fetch Kimai sources
|
||||
###########################
|
||||
|
||||
FROM alpine:latest AS git-prod
|
||||
ARG KIMAI
|
||||
ARG TIMEZONE
|
||||
# the convention in the Kimai repository is: tags are always version numbers, branch names always start with a letter
|
||||
# if the KIMAI variable starts with a number (e.g. 2.24.0) we assume its a tag, otherwise its a branch
|
||||
RUN [[ $KIMAI =~ ^[0-9] ]] && export REF='tags' || export REF='heads' && \
|
||||
wget -O "/opt/kimai.tar.gz" "https://github.com/kimai/kimai/archive/refs/${REF}/${KIMAI}.tar.gz" && \
|
||||
tar -xpzf /opt/kimai.tar.gz -C /opt/ && \
|
||||
mv /opt/kimai-${KIMAI} /opt/kimai
|
||||
|
||||
###########################
|
||||
# global base build
|
||||
###########################
|
||||
@@ -260,7 +247,11 @@ COPY .docker/dbtest.php /dbtest.php
|
||||
COPY .docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
ENV DATABASE_URL="mysql://kimai:kimai@127.0.0.1:3306/kimai?charset=utf8mb4&serverVersion=8.3"
|
||||
ENV APP_SECRET=change_this_to_something_unique
|
||||
# APP_SECRET is intentionally not set here. The entrypoint resolves it (user-provided
|
||||
# via -e APP_SECRET=... wins; otherwise a unique value is generated and persisted to
|
||||
# the var/data volume and mirrored into .env.local). Setting it as a Dockerfile ENV
|
||||
# would create a real env var that always wins over .env*, breaking `docker exec`
|
||||
# console invocations.
|
||||
# The default container name for nginx is nginx
|
||||
ENV TRUSTED_PROXIES=nginx,localhost,127.0.0.1
|
||||
ENV MAILER_FROM=kimai@example.com
|
||||
@@ -283,15 +274,15 @@ CMD [ "/entrypoint.sh" ]
|
||||
|
||||
# development build
|
||||
FROM base AS dev
|
||||
# copy kimai develop source
|
||||
COPY --from=git-prod --chown=www-data:www-data /opt/kimai /opt/kimai
|
||||
# copy kimai source from local build context (see .dockerignore for what is excluded)
|
||||
COPY --chown=www-data:www-data . /opt/kimai
|
||||
COPY .docker /assets
|
||||
# do the composer deps installation
|
||||
RUN \
|
||||
export COMPOSER_HOME=/composer && \
|
||||
composer --no-ansi install --working-dir=/opt/kimai --optimize-autoloader && \
|
||||
composer --no-ansi clearcache && \
|
||||
composer --no-ansi require --working-dir=/opt/kimai laminas/laminas-ldap && \
|
||||
composer --no-ansi clearcache && \
|
||||
cp /usr/local/etc/php/php.ini-development /usr/local/etc/php/php.ini && \
|
||||
chown -R www-data:www-data /opt/kimai /usr/local/etc/php/php.ini && \
|
||||
mkdir -p /opt/kimai/var/logs && chmod 777 /opt/kimai/var/logs && \
|
||||
@@ -304,15 +295,15 @@ ENV memory_limit=512M
|
||||
|
||||
# the "prod" stage (production build) is configured as last stage in the file, as this is the default target in BuildKit
|
||||
FROM base AS prod
|
||||
# copy kimai production source
|
||||
COPY --from=git-prod --chown=www-data:www-data /opt/kimai /opt/kimai
|
||||
# copy kimai source from local build context (see .dockerignore for what is excluded)
|
||||
COPY --chown=www-data:www-data . /opt/kimai
|
||||
COPY .docker /assets
|
||||
# do the composer deps installation
|
||||
RUN \
|
||||
export COMPOSER_HOME=/composer && \
|
||||
composer --no-ansi install --working-dir=/opt/kimai --no-dev --optimize-autoloader && \
|
||||
composer --no-ansi clearcache && \
|
||||
composer --no-ansi require --update-no-dev --working-dir=/opt/kimai laminas/laminas-ldap && \
|
||||
composer --no-ansi clearcache && \
|
||||
cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini && \
|
||||
sed -i "s/expose_php = On/expose_php = Off/g" /usr/local/etc/php/php.ini && \
|
||||
sed -i "s/;opcache.enable=1/opcache.enable=1/g" /usr/local/etc/php/php.ini && \
|
||||
|
||||
11
UPGRADING.md
11
UPGRADING.md
@@ -8,6 +8,17 @@ you can upgrade your Kimai installation to the latest stable release.
|
||||
Check below if there are more version specific steps required, which need to be executed after the normal update process.
|
||||
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
|
||||
|
||||
## [2.58.0](https://github.com/kimai/kimai/releases/tag/2.58.0)
|
||||
|
||||
The official Docker image no longer runs with the default `APP_SECRET=change_this_to_something_unique`.
|
||||
If you did not explicitly set your own `APP_SECRET` (via `-e APP_SECRET=...` or your compose/environment
|
||||
config), the container now generates a unique secret on first start and persists it as `var/data/.appsecret`
|
||||
inside the `data` volume that the documented Docker setup already mounts.
|
||||
|
||||
As a one-time effect of this upgrade, all existing sessions, "remember me" cookies and pending
|
||||
password-reset links become invalid — every user has to log in again once. No manual action is required;
|
||||
the container starts as before. It is recommended to configure your own `APP_SECRET` explicitly.
|
||||
|
||||
## [2.56.0](https://github.com/kimai/kimai/releases/tag/2.56.0)
|
||||
|
||||
The required minimum PHP version is now 8.2, read https://www.php.net/supported-versions.php
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* [KIMAI] KimaiReloadPageWidget: a simple helper to reload the page on events
|
||||
*/
|
||||
|
||||
import { Tooltip } from 'bootstrap';
|
||||
|
||||
export default class KimaiReloadPageWidget {
|
||||
|
||||
constructor(events, fullReload) {
|
||||
@@ -48,8 +50,14 @@ export default class KimaiReloadPageWidget {
|
||||
response.text().then((text) => {
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = text;
|
||||
const oldContent = document.querySelector('section.content');
|
||||
// dispose all tooltips before replacing the content, otherwise an open tooltip
|
||||
// would remain visible in the upper left corner after its anchor element is gone
|
||||
oldContent.querySelectorAll('[data-toggle="tooltip"]').forEach((el) => {
|
||||
Tooltip.getInstance(el)?.dispose();
|
||||
});
|
||||
const newContent = temp.querySelector('section.content');
|
||||
document.querySelector('section.content').replaceWith(newContent);
|
||||
oldContent.replaceWith(newContent);
|
||||
document.dispatchEvent(new Event('kimai.reloadPage'));
|
||||
this._hideOverlay();
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "8.2.*||8.3.*||8.4.*||8.5.*",
|
||||
"php": ">=8.2",
|
||||
"ext-gd": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-json": "*",
|
||||
|
||||
4
composer.lock
generated
4
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "9408273b8cfa9c509c6e483245818e8e",
|
||||
"content-hash": "fa13e2255a6ecd636351e12bf7b69792",
|
||||
"packages": [
|
||||
{
|
||||
"name": "azuyalabs/yasumi",
|
||||
@@ -14691,7 +14691,7 @@
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "8.2.*||8.3.*||8.4.*||8.5.*",
|
||||
"php": ">=8.2",
|
||||
"ext-gd": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-json": "*",
|
||||
|
||||
@@ -72,7 +72,9 @@ security:
|
||||
|
||||
login_link:
|
||||
check_route: link_login_check
|
||||
signature_properties: ['id']
|
||||
# 'password' binds the HMAC signature to the password hash, so a
|
||||
# link becomes invalid as soon as the user changes the password
|
||||
signature_properties: ['id', 'password']
|
||||
lifetime: 900
|
||||
max_uses: 3
|
||||
|
||||
|
||||
@@ -58,9 +58,23 @@ services:
|
||||
arguments:
|
||||
$settings: '%kimai.config%'
|
||||
|
||||
# HTTP client used by mPDF to fetch remote resources (e.g. logos referenced
|
||||
# from custom Twig invoice templates). Decorated with NoPrivateNetworkHttpClient
|
||||
# so requests to private/loopback/link-local targets are blocked at the
|
||||
# network layer — defense in depth for SSRF.
|
||||
# @see https://github.com/kimai/kimai/security/advisories/GHSA-pj8j-p4g4-4vw8
|
||||
Symfony\Component\HttpClient\NoPrivateNetworkHttpClient:
|
||||
arguments:
|
||||
- '@http_client'
|
||||
|
||||
App\Pdf\SafeRemoteContentClient:
|
||||
arguments:
|
||||
- '@Symfony\Component\HttpClient\NoPrivateNetworkHttpClient'
|
||||
|
||||
App\Pdf\MPdfConverter:
|
||||
arguments:
|
||||
$cacheDirectory: '%kernel.cache_dir%'
|
||||
$httpClient: '@App\Pdf\SafeRemoteContentClient'
|
||||
|
||||
App\Utils\FileHelper:
|
||||
arguments:
|
||||
|
||||
10
phpstan.neon
10
phpstan.neon
@@ -3707,16 +3707,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Utils/Parsedown.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:inlineUrl\\(\\) has parameter \\$Excerpt with no type specified\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ParsedownExtension.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:inlineUrl\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ParsedownExtension.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$str of function strtr expects string, string\\|null given\\.$#"
|
||||
count: 1
|
||||
|
||||
2
public/build/app.4f8430f7.js
Normal file
2
public/build/app.4f8430f7.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/app.77b6dec9.js"
|
||||
"/build/app.4f8430f7.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.49955ea2.css"
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"integrity": {
|
||||
"/build/runtime.684e9f6d.js": "sha384-suKiEX2de4fdNqQzdYbUd6osp4AepD9FiMXl+1QdvgMW9dcQqUWQNQasf3KWzwLr",
|
||||
"/build/app.77b6dec9.js": "sha384-qaaKjV9klY58pqcU0Ynu4jjnhO9kZK2kVQD1lcVS1nQs759cFYIRfXpTXmGYppei",
|
||||
"/build/app.4f8430f7.js": "sha384-X029h9FgtC24+GvFSqlEVnKRrumwmpyWaD8nkFZlIYfDoV3/43Gnj5A4s88GopVD",
|
||||
"/build/app.49955ea2.css": "sha384-8ix/CKnR2d1mU3tMEFJPrOOPtQA2tdzHugEJMnODX+7my6dFwlworNdfZwrwuvz5",
|
||||
"/build/app-rtl.88289026.js": "sha384-gOohic29TurHJC5g2lJbDPbhnbrpZv8dCzS2QrUOHToldu1kIhhV+nwIhrIcvh2M",
|
||||
"/build/app-rtl.e8e3029e.css": "sha384-21kGyBRbajbE/gt4g8ej+clQSGAvYx1BMmiUMRTAE3ZVpMb5bvO/kVRcdKuV97J5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"build/app.css": "/build/app.49955ea2.css",
|
||||
"build/app.js": "/build/app.77b6dec9.js",
|
||||
"build/app.js": "/build/app.4f8430f7.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.e8e3029e.css",
|
||||
"build/app-rtl.js": "/build/app-rtl.88289026.js",
|
||||
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
|
||||
|
||||
1
public/wizard/completed.svg
Normal file
1
public/wizard/completed.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 39 KiB |
1
public/wizard/time-management.svg
Normal file
1
public/wizard/time-management.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.7 KiB |
@@ -18,6 +18,7 @@ use App\Repository\ActivityRateRepository;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\User\TeamService;
|
||||
use App\Utils\SearchTerm;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
@@ -27,6 +28,7 @@ use OpenApi\Attributes as OA;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
@@ -308,4 +310,39 @@ final class ActivityController extends BaseApiController
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create team for activity
|
||||
*
|
||||
* If a team with the activity's name already exists, it is reused.
|
||||
* The current user is added as teamlead (if not already), and the activity is bound to the team.
|
||||
*/
|
||||
#[IsGranted('create_team')]
|
||||
#[IsGranted('permissions', 'activity')]
|
||||
#[OA\Post(description: 'Creates (or reuses) a default team named after the activity, makes the current user a teamlead, and binds the activity to that team. Calling this multiple times is safe and will not create duplicate teams or bindings.', responses: [new OA\Response(response: 200, description: 'Returns the team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', description: 'The activity to create a default team for', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}/team', name: 'post_activity_team', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function postDefaultTeamAction(Activity $activity, TeamService $teamService): Response
|
||||
{
|
||||
$name = $activity->getName();
|
||||
if ($name === null || $name === '') {
|
||||
throw new BadRequestHttpException('Cannot create default team for activity with empty name: ' . $activity->getId());
|
||||
}
|
||||
|
||||
$team = $teamService->findTeamByName($name);
|
||||
|
||||
if ($team === null) {
|
||||
$team = $teamService->createNewTeam($name);
|
||||
}
|
||||
|
||||
$team->addTeamlead($this->getUser());
|
||||
$team->addActivity($activity);
|
||||
|
||||
$teamService->saveTeam($team);
|
||||
|
||||
$view = new View($team, Response::HTTP_OK);
|
||||
$view->getContext()->setGroups(TeamController::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use App\Form\API\CustomerRateApiForm;
|
||||
use App\Repository\CustomerRateRepository;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\User\TeamService;
|
||||
use App\Utils\SearchTerm;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
@@ -29,6 +30,7 @@ use OpenApi\Attributes as OA;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
@@ -401,4 +403,39 @@ final class CustomerController extends BaseApiController
|
||||
|
||||
return $this->viewHandler->handle(new View(null, Response::HTTP_NO_CONTENT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create team for customer
|
||||
*
|
||||
* If a team with the customer's name already exists, it is reused.
|
||||
* The current user is added as teamlead (if not already), and the customer is bound to the team.
|
||||
*/
|
||||
#[IsGranted('create_team')]
|
||||
#[IsGranted('permissions', 'customer')]
|
||||
#[OA\Post(description: 'Creates (or reuses) a default team named after the customer, makes the current user a teamlead, and binds the customer to that team. Calling this multiple times is safe and will not create duplicate teams or bindings.', responses: [new OA\Response(response: 200, description: 'Returns the team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', description: 'The customer to create a default team for', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}/team', name: 'post_customer_team', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function postDefaultTeamAction(Customer $customer, TeamService $teamService): Response
|
||||
{
|
||||
$name = $customer->getName();
|
||||
if ($name === null || $name === '') {
|
||||
throw new BadRequestHttpException('Cannot create default team for customer with empty name: ' . $customer->getId());
|
||||
}
|
||||
|
||||
$team = $teamService->findTeamByName($name);
|
||||
|
||||
if ($team === null) {
|
||||
$team = $teamService->createNewTeam($name);
|
||||
}
|
||||
|
||||
$team->addTeamlead($this->getUser());
|
||||
$team->addCustomer($customer);
|
||||
|
||||
$teamService->saveTeam($team);
|
||||
|
||||
$view = new View($team, Response::HTTP_OK);
|
||||
$view->getContext()->setGroups(TeamController::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use App\Repository\CustomerRepository;
|
||||
use App\Repository\ProjectRateRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\User\TeamService;
|
||||
use App\Utils\SearchTerm;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
@@ -30,6 +31,7 @@ use OpenApi\Attributes as OA;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Validator\Constraints;
|
||||
@@ -457,4 +459,39 @@ final class ProjectController extends BaseApiController
|
||||
|
||||
return $this->viewHandler->handle(new View(null, Response::HTTP_NO_CONTENT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create team for project
|
||||
*
|
||||
* If a team with the project's name already exists, it is reused.
|
||||
* The current user is added as teamlead (if not already), and the project is bound to the team.
|
||||
*/
|
||||
#[IsGranted('create_team')]
|
||||
#[IsGranted('permissions', 'project')]
|
||||
#[OA\Post(description: 'Creates (or reuses) a default team named after the project, makes the current user a teamlead, and binds the project to that team. Calling this multiple times is safe and will not create duplicate teams or bindings.', responses: [new OA\Response(response: 200, description: 'Returns the team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', description: 'The project to create a default team for', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}/team', name: 'post_project_team', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function postDefaultTeamAction(Project $project, TeamService $teamService): Response
|
||||
{
|
||||
$name = $project->getName();
|
||||
if ($name === null || $name === '') {
|
||||
throw new BadRequestHttpException('Cannot create default team for project with empty name: ' . $project->getId());
|
||||
}
|
||||
|
||||
$team = $teamService->findTeamByName($name);
|
||||
|
||||
if ($team === null) {
|
||||
$team = $teamService->createNewTeam($name);
|
||||
}
|
||||
|
||||
$team->addTeamlead($this->getUser());
|
||||
$team->addProject($project);
|
||||
|
||||
$teamService->saveTeam($team);
|
||||
|
||||
$view = new View($team, Response::HTTP_OK);
|
||||
$view->getContext()->setGroups(TeamController::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ final class TeamController extends BaseApiController
|
||||
* Add team member
|
||||
*/
|
||||
#[IsGranted('edit', 'team')]
|
||||
#[IsGranted('access_user', 'member')]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new user to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The team which will receive the new member', required: true)]
|
||||
#[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to add (User ID)', required: true)]
|
||||
@@ -224,6 +225,7 @@ final class TeamController extends BaseApiController
|
||||
* The team is granted access to the customer.
|
||||
*/
|
||||
#[IsGranted('edit', 'team')]
|
||||
#[IsGranted('view', 'customer')]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the customer', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
|
||||
#[OA\Parameter(name: 'customerId', in: 'path', description: 'The customer to grant acecess to (Customer ID)', required: true)]
|
||||
@@ -274,6 +276,7 @@ final class TeamController extends BaseApiController
|
||||
* The team is granted access to the project.
|
||||
*/
|
||||
#[IsGranted('edit', 'team')]
|
||||
#[IsGranted('view', 'project')]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the project', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
|
||||
#[OA\Parameter(name: 'projectId', in: 'path', description: 'The project to grant acecess to (Project ID)', required: true)]
|
||||
@@ -324,6 +327,7 @@ final class TeamController extends BaseApiController
|
||||
* The team is granted access to the activity.
|
||||
*/
|
||||
#[IsGranted('edit', 'team')]
|
||||
#[IsGranted('view', 'activity')]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the activity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
|
||||
#[OA\Parameter(name: 'activityId', in: 'path', description: 'The activity to grant acecess to (Activity ID)', required: true)]
|
||||
|
||||
@@ -429,16 +429,11 @@ final class TimesheetController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Stop active timesheet
|
||||
*
|
||||
* This route is available via GET and PATCH, as users over and over again run into errors when stopping.
|
||||
* Likely caused by a slow JS engine and a fast-click after page reload.
|
||||
*/
|
||||
#[IsGranted('stop', 'timesheet')]
|
||||
#[OA\Response(response: 200, description: 'Stops an active timesheet and returns it afterwards.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet ID to stop', required: true)]
|
||||
#[Route(methods: ['GET'], path: '/{id}/stop', name: 'stop_timesheet_get', requirements: ['id' => '\d+'])]
|
||||
#[Route(methods: ['PATCH'], path: '/{id}/stop', name: 'stop_timesheet', requirements: ['id' => '\d+'])]
|
||||
#[OA\Get(x: ['internal' => true])]
|
||||
public function stopAction(Timesheet $timesheet): Response
|
||||
{
|
||||
$this->service->stopTimesheet($timesheet);
|
||||
@@ -457,8 +452,6 @@ final class TimesheetController extends BaseApiController
|
||||
#[IsGranted('start', 'timesheet')]
|
||||
#[OA\Response(response: 200, description: 'Restart a timesheet for the same customer, project, activity combination. The current user will be the owner of the new record. Kimai tries to stop running records, which is expected to fail depending on the configured rules. Data will be copied from the original record if requested.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet ID to restart', required: true)]
|
||||
#[OA\Get(x: ['internal' => true])]
|
||||
#[Route(methods: ['GET'], path: '/{id}/restart', name: 'restart_timesheet_get', requirements: ['id' => '\d+'])]
|
||||
#[Route(methods: ['PATCH'], path: '/{id}/restart', name: 'restart_timesheet', requirements: ['id' => '\d+'])]
|
||||
#[Rest\RequestParam(name: 'copy', requirements: 'all', strict: true, nullable: true, description: 'Whether data should be copied to the new entry. Allowed values: all (default: nothing is copied)')]
|
||||
#[Rest\RequestParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Changes the restart date to the given one (default: now)')]
|
||||
|
||||
@@ -17,11 +17,11 @@ final class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.57.0';
|
||||
public const VERSION = '2.58.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 25700;
|
||||
public const VERSION_ID = 25800;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -31,7 +31,6 @@ use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\TeamQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\User\TeamService;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
use Exception;
|
||||
@@ -40,7 +39,6 @@ use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
@@ -306,34 +304,6 @@ final class ActivityController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/create_team', name: 'activity_team_create', methods: ['GET'])]
|
||||
#[IsGranted('create_team')]
|
||||
#[IsGranted('permissions', 'activity')]
|
||||
public function createDefaultTeamAction(Activity $activity, TeamService $teamService): Response
|
||||
{
|
||||
$name = $activity->getName();
|
||||
if ($name === null) {
|
||||
throw new BadRequestHttpException('Cannot create default team for activity with empty name: ' . $activity->getId());
|
||||
}
|
||||
|
||||
$defaultTeam = $teamService->findTeamByName($name);
|
||||
|
||||
if (null === $defaultTeam) {
|
||||
$defaultTeam = $teamService->createNewTeam($name);
|
||||
}
|
||||
|
||||
$defaultTeam->addTeamlead($this->getUser());
|
||||
$defaultTeam->addActivity($activity);
|
||||
|
||||
try {
|
||||
$teamService->saveTeam($defaultTeam);
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('activity_details', ['id' => $activity->getId()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/edit', name: 'admin_activity_edit', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('edit', 'activity')]
|
||||
public function editAction(Activity $activity, Request $request, ActivityService $activityService, SystemConfiguration $configuration): Response
|
||||
|
||||
@@ -34,7 +34,6 @@ use App\Repository\Query\TeamQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\Query\VisibilityInterface;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\User\TeamService;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
@@ -42,7 +41,6 @@ use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
@@ -188,34 +186,6 @@ final class CustomerController extends AbstractController
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/create_team', name: 'customer_team_create', methods: ['GET'])]
|
||||
#[IsGranted('create_team')]
|
||||
#[IsGranted('permissions', 'customer')]
|
||||
public function createDefaultTeamAction(Customer $customer, TeamService $teamService): Response
|
||||
{
|
||||
$name = $customer->getName();
|
||||
if ($name === null) {
|
||||
throw new BadRequestHttpException('Cannot create default team for customer with empty name: ' . $customer->getId());
|
||||
}
|
||||
|
||||
$defaultTeam = $teamService->findTeamByName($name);
|
||||
|
||||
if (null === $defaultTeam) {
|
||||
$defaultTeam = $teamService->createNewTeam($name);
|
||||
}
|
||||
|
||||
$defaultTeam->addTeamlead($this->getUser());
|
||||
$defaultTeam->addCustomer($customer);
|
||||
|
||||
try {
|
||||
$teamService->saveTeam($defaultTeam);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/projects/{page}', defaults: ['page' => 1], name: 'customer_projects', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('view', 'customer')]
|
||||
public function projectsAction(Customer $customer, int $page, ProjectRepository $projectRepository): Response
|
||||
|
||||
@@ -32,6 +32,7 @@ final class DoctorController extends AbstractController
|
||||
public const DIRECTORIES_WRITABLE = [
|
||||
'var/cache/',
|
||||
'var/log/',
|
||||
'var/packages/',
|
||||
];
|
||||
|
||||
public function __construct(private string $projectDirectory, private string $kernelEnvironment, private FileHelper $fileHelper, private CacheInterface $cache)
|
||||
|
||||
@@ -208,12 +208,14 @@ final class ExportController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route(path: '/template-create', name: 'export_template_create', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_export_template')]
|
||||
public function createExportTemplate(Request $request, ExportTemplateRepository $repository): Response
|
||||
{
|
||||
return $this->editExportForm($this->generateUrl('export_template_create'), $request, $repository, new ExportTemplate());
|
||||
}
|
||||
|
||||
#[Route(path: '/template-edit/{exportTemplate}', name: 'export_template_edit', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_export_template')]
|
||||
public function editExportTemplate(ExportTemplate $exportTemplate, Request $request, ExportTemplateRepository $repository): Response
|
||||
{
|
||||
return $this->editExportForm($this->generateUrl('export_template_edit', ['exportTemplate' => $exportTemplate->getId()]), $request, $repository, $exportTemplate);
|
||||
|
||||
@@ -37,7 +37,6 @@ use App\Repository\Query\TeamQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\Query\VisibilityInterface;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\User\TeamService;
|
||||
use App\Utils\Context;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
@@ -46,7 +45,6 @@ use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Csrf\CsrfToken;
|
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
@@ -219,34 +217,6 @@ final class ProjectController extends AbstractController
|
||||
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/create_team', name: 'project_team_create', methods: ['GET'])]
|
||||
#[IsGranted('create_team')]
|
||||
#[IsGranted('permissions', 'project')]
|
||||
public function createDefaultTeamAction(Project $project, TeamService $teamService): Response
|
||||
{
|
||||
$name = $project->getName();
|
||||
if ($name === null) {
|
||||
throw new BadRequestHttpException('Cannot create default team for project with empty name: ' . $project->getId());
|
||||
}
|
||||
|
||||
$defaultTeam = $teamService->findTeamByName($name);
|
||||
|
||||
if (null === $defaultTeam) {
|
||||
$defaultTeam = $teamService->createNewTeam($name);
|
||||
}
|
||||
|
||||
$defaultTeam->addTeamlead($this->getUser());
|
||||
$defaultTeam->addProject($project);
|
||||
|
||||
try {
|
||||
$teamService->saveTeam($defaultTeam);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/activities/{page}', defaults: ['page' => 1], name: 'project_activities', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('view', 'project')]
|
||||
public function activitiesAction(Project $project, int $page, ActivityRepository $activityRepository): Response
|
||||
|
||||
@@ -25,11 +25,11 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
|
||||
$timesheet = $payload['timesheet'];
|
||||
if ($timesheet->getId() !== null) {
|
||||
if ($timesheet->isRunning() && $this->isGranted('stop', $timesheet)) {
|
||||
$event->addAction('stop', ['url' => $this->path('stop_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link dd-ts-stop', 'attr' => ['data-event' => 'kimai.timesheetStop kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.stop.error', 'data-msg-success' => 'timesheet.stop.success']]);
|
||||
$event->addAction('stop', ['url' => '#', 'class' => 'api-link dd-ts-stop', 'attr' => ['data-event' => 'kimai.timesheetStop kimai.timesheetUpdate', 'data-href' => $this->path('stop_timesheet', ['id' => $timesheet->getId()]), 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.stop.error', 'data-msg-success' => 'timesheet.stop.success']]);
|
||||
}
|
||||
|
||||
if (!$timesheet->isRunning() && $this->isGranted('start', $timesheet)) {
|
||||
$event->addAction('repeat', ['title' => 'repeat', 'url' => $this->path('restart_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link dd-ts-repeat', 'attr' => ['data-payload' => '{"copy": "all"}', 'data-event' => 'kimai.timesheetStart kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.start.error', 'data-msg-success' => 'timesheet.start.success']]);
|
||||
$event->addAction('repeat', ['title' => 'repeat', 'url' => '#', 'class' => 'api-link dd-ts-repeat', 'attr' => ['data-payload' => '{"copy": "all"}', 'data-event' => 'kimai.timesheetStart kimai.timesheetUpdate', 'data-href' => $this->path('restart_timesheet', ['id' => $timesheet->getId()]), 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.start.error', 'data-msg-success' => 'timesheet.start.success']]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('edit', $timesheet)) {
|
||||
|
||||
70
src/EventSubscriber/PasswordResetSubscriber.php
Normal file
70
src/EventSubscriber/PasswordResetSubscriber.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
class PasswordResetSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly AuthorizationCheckerInterface $security,
|
||||
private readonly TokenStorageInterface $storage,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
// higher priority is executed earlier - need to be higher than wizard
|
||||
KernelEvents::REQUEST => ['onKernelRequest', -20]
|
||||
];
|
||||
}
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
// ignore sub-requests
|
||||
if (!$event->isMainRequest() || null === ($token = $this->storage->getToken())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uri = $event->getRequest()->getRequestUri();
|
||||
|
||||
// never trigger password reset on API calls
|
||||
// TODO 3.0 remove /register/
|
||||
if (str_starts_with($uri, '/api/') || stripos($uri, '/register/') !== false || stripos($uri, '/wizard/') !== false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!($user instanceof User)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted('IS_AUTHENTICATED_FULLY')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$user->requiresPasswordReset()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response = new RedirectResponse($this->urlGenerator->generate('wizard', ['wizard' => 'password']));
|
||||
$event->setResponse($response);
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,13 @@
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
|
||||
/**
|
||||
* When visiting the homepage, this listener redirects the user to the most
|
||||
@@ -24,7 +26,8 @@ final class RedirectToLocaleSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly LocaleService $localeService
|
||||
private readonly LocaleService $localeService,
|
||||
private readonly TokenStorageInterface $storage,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -32,7 +35,9 @@ final class RedirectToLocaleSubscriber implements EventSubscriberInterface
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
KernelEvents::REQUEST => ['onKernelRequest']
|
||||
// the higher the priority (default: 0), the earlier it is executed
|
||||
// runs on default priority to make sure we have the correct locale in the URL
|
||||
KernelEvents::REQUEST => ['onKernelRequest', 0]
|
||||
];
|
||||
}
|
||||
|
||||
@@ -52,6 +57,16 @@ final class RedirectToLocaleSubscriber implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
|
||||
$preferredLanguage = null;
|
||||
|
||||
if (null !== ($token = $this->storage->getToken())) {
|
||||
$user = $token->getUser();
|
||||
if ($user instanceof User) {
|
||||
$preferredLanguage = $user->getLanguage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($preferredLanguage === null){
|
||||
$allLanguages = $this->localeService->getTranslatedLocales();
|
||||
|
||||
// Add the default locale at the first position of the array, because getPreferredLanguage()
|
||||
@@ -59,8 +74,9 @@ final class RedirectToLocaleSubscriber implements EventSubscriberInterface
|
||||
array_unshift($allLanguages, 'en');
|
||||
|
||||
$preferredLanguage = $request->getPreferredLanguage(array_unique($allLanguages));
|
||||
}
|
||||
|
||||
$response = new RedirectResponse($this->urlGenerator->generate('homepage', ['_locale' => $preferredLanguage]));
|
||||
$response = new RedirectResponse($this->urlGenerator->generate('homepage', ['_locale' => $preferredLanguage ?? 'en']));
|
||||
$event->setResponse($response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\EventSubscriber;
|
||||
use App\Entity\User;
|
||||
use App\Twig\LocaleFormatExtensions;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\FinishRequestEvent;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
@@ -19,6 +20,8 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
final class UserEnvironmentSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
private ?string $userLocale = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly TokenStorageInterface $tokenStorage,
|
||||
private readonly AuthorizationCheckerInterface $auth,
|
||||
@@ -30,10 +33,30 @@ final class UserEnvironmentSubscriber implements EventSubscriberInterface
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
KernelEvents::REQUEST => ['prepareEnvironment', -100],
|
||||
// runs as first one in Kimai, to make sure we use the correct locales for rendering
|
||||
KernelEvents::REQUEST => ['prepareEnvironment', -10],
|
||||
// don't know why do we use -20
|
||||
KernelEvents::FINISH_REQUEST => ['restoreLocale', -20],
|
||||
];
|
||||
}
|
||||
|
||||
public function restoreLocale(FinishRequestEvent $event): void
|
||||
{
|
||||
if ($event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->userLocale === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// LocaleSwitcher (called by LocaleAwareListener) overwrites \Locale::getDefault() with the URL
|
||||
// locale during sub-requests. Restore both the PHP default and the Twig formatter locale to
|
||||
// the user's formatting locale that was saved during the main request.
|
||||
\Locale::setDefault($this->userLocale);
|
||||
$this->localeFormatExtensions->setLocale($this->userLocale);
|
||||
}
|
||||
|
||||
public function prepareEnvironment(RequestEvent $event): void
|
||||
{
|
||||
// ignore sub-requests
|
||||
@@ -55,6 +78,7 @@ final class UserEnvironmentSubscriber implements EventSubscriberInterface
|
||||
}
|
||||
|
||||
// the locale is primarily used for formatting values, so we depend on the user locale if available
|
||||
$this->userLocale = $locale;
|
||||
\Locale::setDefault($locale);
|
||||
$this->localeFormatExtensions->setLocale($locale);
|
||||
}
|
||||
|
||||
@@ -22,35 +22,31 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
class WizardSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private UrlGeneratorInterface $urlGenerator,
|
||||
private AuthorizationCheckerInterface $security,
|
||||
private TokenStorageInterface $storage,
|
||||
private SystemConfiguration $systemConfiguration
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly AuthorizationCheckerInterface $security,
|
||||
private readonly TokenStorageInterface $storage,
|
||||
private readonly SystemConfiguration $systemConfiguration
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
KernelEvents::REQUEST => ['onKernelRequest']
|
||||
KernelEvents::REQUEST => ['onKernelRequest', -30]
|
||||
];
|
||||
}
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
// ignore sub-requests
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore events like the toolbar where we do not have a token
|
||||
if (null === ($token = $this->storage->getToken())) {
|
||||
// ignore sub-requests and un-authenticated events
|
||||
if (!$event->isMainRequest() || null === ($token = $this->storage->getToken())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uri = $event->getRequest()->getRequestUri();
|
||||
|
||||
// never require 2FA on API calls
|
||||
// never trigger wizard on API calls
|
||||
// TODO 3.0 remove /register/
|
||||
if (str_starts_with($uri, '/api/') || stripos($uri, '/register/') !== false || stripos($uri, '/wizard/') !== false) {
|
||||
return;
|
||||
}
|
||||
@@ -65,11 +61,6 @@ class WizardSubscriber implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user->requiresPasswordReset()) {
|
||||
$response = new RedirectResponse($this->urlGenerator->generate('wizard', ['wizard' => 'password']));
|
||||
$event->setResponse($response);
|
||||
}
|
||||
|
||||
if ($user->isRegularUserOnly() && !$this->systemConfiguration->isUserWizardActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ use App\Constants;
|
||||
use App\Utils\FileHelper;
|
||||
use Mpdf\Config\ConfigVariables;
|
||||
use Mpdf\Config\FontVariables;
|
||||
use Mpdf\Container\SimpleContainer;
|
||||
use Mpdf\Http\ClientInterface;
|
||||
use Mpdf\Mpdf;
|
||||
use Mpdf\Output\Destination;
|
||||
|
||||
@@ -20,7 +22,8 @@ final class MPdfConverter implements HtmlToPdfConverter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FileHelper $fileHelper,
|
||||
private readonly string $cacheDirectory
|
||||
private readonly string $cacheDirectory,
|
||||
private readonly ?ClientInterface $httpClient = null,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -116,7 +119,17 @@ final class MPdfConverter implements HtmlToPdfConverter
|
||||
unset($options['additional_xmp_rdf']);
|
||||
}
|
||||
|
||||
$mpdf = new Mpdf($options);
|
||||
// Inject a safe HTTP client into mPDF (via its service container) so
|
||||
// remote resources referenced from Twig templates — typically `<img
|
||||
// src="...">` for company logos — cannot be abused to probe private
|
||||
// networks. The configured Symfony client is decorated with
|
||||
// NoPrivateNetworkHttpClient at the service-container level.
|
||||
// @see https://github.com/kimai/kimai/security/advisories/GHSA-pj8j-p4g4-4vw8
|
||||
$container = $this->httpClient !== null
|
||||
? new SimpleContainer(['httpClient' => $this->httpClient])
|
||||
: null;
|
||||
|
||||
$mpdf = new Mpdf($options, $container);
|
||||
$mpdf->creator = Constants::SOFTWARE;
|
||||
|
||||
if (\count($associatedFiles) > 0) {
|
||||
|
||||
76
src/Pdf/SafeRemoteContentClient.php
Normal file
76
src/Pdf/SafeRemoteContentClient.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Pdf;
|
||||
|
||||
use Mpdf\Http\ClientInterface;
|
||||
use Mpdf\PsrHttpMessageShim\Response;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as HttpClientExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* Bridges mPDF's HTTP client to Symfonys NoPrivateNetworkHttpClient.
|
||||
*
|
||||
* Prevents the PDF renderer from issuing outbound requests to private network targets,
|
||||
* closing the SSRF vector for `<img src="...">` references in custom Twig invoice templates.
|
||||
*
|
||||
* Blocked requests are translated to a non-2xx response so mPDF logs the failure
|
||||
* and renders a placeholder for the missing image without aborting PDF generation.
|
||||
*
|
||||
* @see https://github.com/kimai/kimai/security/advisories/GHSA-pj8j-p4g4-4vw8
|
||||
*/
|
||||
final class SafeRemoteContentClient implements ClientInterface
|
||||
{
|
||||
/**
|
||||
* Timeout in seconds: a slow or unreachable remote target should not block PDF rendering.
|
||||
*/
|
||||
private const TIMEOUT = 10;
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $client)
|
||||
{
|
||||
}
|
||||
|
||||
public function sendRequest(RequestInterface $request): Response
|
||||
{
|
||||
try {
|
||||
$response = $this->client->request(
|
||||
$request->getMethod(),
|
||||
(string) $request->getUri(),
|
||||
[
|
||||
'headers' => $this->flattenHeaders($request),
|
||||
'timeout' => self::TIMEOUT,
|
||||
'max_duration' => self::TIMEOUT,
|
||||
]
|
||||
);
|
||||
|
||||
return new Response(
|
||||
$response->getStatusCode(),
|
||||
[],
|
||||
$response->getContent(false)
|
||||
);
|
||||
} catch (HttpClientExceptionInterface) {
|
||||
// Request blocked (private network), DNS failure, timeout, etc.
|
||||
return new Response(502);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function flattenHeaders(RequestInterface $request): array
|
||||
{
|
||||
$headers = [];
|
||||
foreach ($request->getHeaders() as $name => $values) {
|
||||
$headers[$name] = implode(', ', $values);
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,10 @@ final class PackageManager
|
||||
*/
|
||||
private function findAvailablePackages(string $path): array
|
||||
{
|
||||
if (!file_exists($path) || !is_readable($path) || !is_dir($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$packages = [];
|
||||
|
||||
$directory = new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::FOLLOW_SYMLINKS);
|
||||
|
||||
@@ -199,7 +199,7 @@ final class RolePermissionManager
|
||||
return $this->checkTeamLeadAccess($timesheet->getUser()?->getTeams() ?? [], $user);
|
||||
}
|
||||
|
||||
public function checkUserAccess(User $subject, User $user): bool
|
||||
public function checkUserAccess(User $subject, User $user, bool $onlyEnabled = true): bool
|
||||
{
|
||||
if ($subject->getId() === $user->getId()) {
|
||||
return true;
|
||||
@@ -209,10 +209,12 @@ final class RolePermissionManager
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!$subject->isEnabled()) {
|
||||
if ($onlyEnabled && !$subject->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// system accounts are used for admins or API-only accounts
|
||||
// and should not be accessed by less-privileged users (e.g. teamleads)
|
||||
if (!$user->isSystemAccount() && $subject->isSystemAccount()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ final class MarkdownExtension implements RuntimeExtensionInterface
|
||||
{
|
||||
private ?bool $markdownEnabled = null;
|
||||
|
||||
public function __construct(private Markdown $markdown, private SystemConfiguration $configuration)
|
||||
public function __construct(
|
||||
private readonly Markdown $markdown,
|
||||
private readonly SystemConfiguration $configuration
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -32,10 +35,6 @@ final class MarkdownExtension implements RuntimeExtensionInterface
|
||||
|
||||
/**
|
||||
* Transforms entity and user comments (customer, project, activity ...) into HTML.
|
||||
*
|
||||
* @param string|null $content
|
||||
* @param bool $fullLength
|
||||
* @return string
|
||||
*/
|
||||
public function commentContent(?string $content, bool $fullLength = true): string
|
||||
{
|
||||
@@ -58,10 +57,6 @@ final class MarkdownExtension implements RuntimeExtensionInterface
|
||||
|
||||
/**
|
||||
* Transforms the entities comment (customer, project, activity ...) into a one-liner.
|
||||
*
|
||||
* @param string|null $content
|
||||
* @param bool $fullLength
|
||||
* @return string
|
||||
*/
|
||||
public function commentOneLiner(?string $content, bool $fullLength = true): string
|
||||
{
|
||||
@@ -88,9 +83,6 @@ final class MarkdownExtension implements RuntimeExtensionInterface
|
||||
|
||||
/**
|
||||
* Transforms the timesheet description content into HTML.
|
||||
*
|
||||
* @param string|null $content
|
||||
* @return string
|
||||
*/
|
||||
public function timesheetContent(?string $content): string
|
||||
{
|
||||
@@ -107,9 +99,6 @@ final class MarkdownExtension implements RuntimeExtensionInterface
|
||||
|
||||
/**
|
||||
* Transforms the given Markdown content into HTML
|
||||
*
|
||||
* @param string $content
|
||||
* @return string
|
||||
*/
|
||||
public function markdownToHtml(string $content): string
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace App\Utils;
|
||||
final class Markdown
|
||||
{
|
||||
private ?ParsedownExtension $parser = null;
|
||||
private ?\Parsedown $parserFull = null;
|
||||
private ?Parsedown $parserFull = null;
|
||||
|
||||
public function toHtml(string $text): string
|
||||
{
|
||||
|
||||
@@ -17,6 +17,20 @@ class Parsedown extends \Parsedown
|
||||
/** @var array<string> */
|
||||
private array $ids = [];
|
||||
|
||||
/**
|
||||
* Overwritten to open links in new windows
|
||||
*/
|
||||
protected function inlineUrl($Excerpt): ?array // @phpstan-ignore missingType.parameter,missingType.iterableValue
|
||||
{
|
||||
$block = parent::inlineUrl($Excerpt);
|
||||
|
||||
if (isset($block['element']['attributes']) && \is_array($block['element']['attributes'])) {
|
||||
$block['element']['attributes']['target'] = '_blank';
|
||||
}
|
||||
|
||||
return $block;
|
||||
}
|
||||
|
||||
protected function blockHeader($Line)
|
||||
{
|
||||
$block = parent::blockHeader($Line);
|
||||
@@ -84,4 +98,42 @@ class Parsedown extends \Parsedown
|
||||
|
||||
return $Block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown image syntax `` is rewritten to a link `<a href="url">alt</a>`.
|
||||
*
|
||||
* Rationale: emitting `<img src="url">` would cause downstream renderers
|
||||
* (e.g. mPDF on the server, browsers in the UI) to automatically fetch
|
||||
* the remote URL. For server-side renderers this is a server-side request
|
||||
* forgery vector; in the UI it is a tracking/privacy issue. Hand-written
|
||||
* `<img>` in Twig templates (custom invoice templates etc.) is not
|
||||
* affected — only images derived from Markdown input are neutralised
|
||||
* here. The resulting `<a href>` is still passed through Parsedown's
|
||||
* `safeLinksWhitelist` filtering when safe-mode is enabled.
|
||||
*
|
||||
* @see https://github.com/kimai/kimai/security/advisories/GHSA-pj8j-p4g4-4vw8
|
||||
*/
|
||||
protected function inlineImage($Excerpt): ?array // @phpstan-ignore missingType.parameter,missingType.iterableValue
|
||||
{
|
||||
$Image = parent::inlineImage($Excerpt);
|
||||
|
||||
if ($Image === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$src = $Image['element']['attributes']['src'] ?? '';
|
||||
$alt = $Image['element']['attributes']['alt'] ?? '';
|
||||
|
||||
$Image['element'] = [
|
||||
'name' => 'a',
|
||||
'text' => $alt !== '' ? $alt : $src,
|
||||
'attributes' => [
|
||||
'href' => $src,
|
||||
'rel' => 'noopener noreferrer',
|
||||
'target' => '_blank',
|
||||
],
|
||||
];
|
||||
|
||||
return $Image;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
namespace App\Utils;
|
||||
|
||||
/**
|
||||
* This Class extends the default Parsedown Class for custom methods.
|
||||
* The default markdown implementation.
|
||||
*/
|
||||
final class ParsedownExtension extends Parsedown
|
||||
{
|
||||
@@ -42,31 +42,4 @@ final class ParsedownExtension extends Parsedown
|
||||
'|' => ['Table'],
|
||||
'~' => ['FencedCode'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Overwritten to open links in new windows
|
||||
*/
|
||||
protected function inlineUrl($Excerpt): ?array
|
||||
{
|
||||
$block = parent::inlineUrl($Excerpt);
|
||||
|
||||
if (isset($block['element']['attributes']) && \is_array($block['element']['attributes'])) {
|
||||
$block['element']['attributes']['target'] = '_blank';
|
||||
}
|
||||
|
||||
return $block;
|
||||
}
|
||||
|
||||
protected function blockTable($Line, ?array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter
|
||||
{
|
||||
$Block = parent::blockTable($Line, $Block);
|
||||
|
||||
if ($Block === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$Block['element']['attributes']['class'] = 'table';
|
||||
|
||||
return $Block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ final class TimesheetVoter extends Voter
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!($user instanceof User)) {
|
||||
if (!($user instanceof User) || $user->getId() === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -91,10 +91,10 @@ final class TimesheetVoter extends Voter
|
||||
|
||||
switch ($attribute) {
|
||||
case 'is_owner':
|
||||
return (!$subject instanceof MultiUserTimesheet) && $user === $subject->getUser();
|
||||
return (!$subject instanceof MultiUserTimesheet) && $user->getId() === $subject->getUser()?->getId();
|
||||
|
||||
case self::START:
|
||||
if (!$this->canStart($subject)) {
|
||||
if (!$this->canStart($user, $subject)) {
|
||||
return false;
|
||||
}
|
||||
$permission .= $attribute;
|
||||
@@ -115,7 +115,7 @@ final class TimesheetVoter extends Voter
|
||||
break;
|
||||
|
||||
case 'duplicate':
|
||||
if (!$this->canStart($subject)) {
|
||||
if (!$this->canStart($user, $subject)) {
|
||||
return false;
|
||||
}
|
||||
$permission = self::EDIT;
|
||||
@@ -146,11 +146,10 @@ final class TimesheetVoter extends Voter
|
||||
return $this->permissionManager->hasRolePermission($user, $permission . '_other_timesheet');
|
||||
}
|
||||
|
||||
private function canStart(Timesheet $timesheet): bool
|
||||
private function canStart(User $user, Timesheet $timesheet): bool
|
||||
{
|
||||
// possible improvements for the future:
|
||||
// we could check the amount of active entries (maybe slow)
|
||||
// if a teamlead starts an entry for another user, check that this user is part of his team (needs to be done for teams)
|
||||
|
||||
if (null === $timesheet->getActivity()) {
|
||||
return false;
|
||||
@@ -172,6 +171,18 @@ final class TimesheetVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
// starting and duplicating both create a NEW record under the referenced
|
||||
// project and activity, so the current user must still have team-based
|
||||
// access to them - historical ownership of the original timesheet is not
|
||||
// sufficient (otherwise old entries would survive an access revocation).
|
||||
if (!$this->permissionManager->checkTeamAccessProject($timesheet->getProject(), $user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->permissionManager->checkTeamAccessActivity($timesheet->getActivity(), $user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,11 +62,7 @@ final class UserVoter extends Voter
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!($user instanceof User)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!($subject instanceof User)) {
|
||||
if (!($user instanceof User) || !($subject instanceof User)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -111,15 +107,14 @@ final class UserVoter extends Voter
|
||||
|
||||
$permission = $attribute;
|
||||
|
||||
// extend me for "team" support later on
|
||||
if ($subject->getId() === $user->getId()) {
|
||||
$permission .= '_own';
|
||||
} else {
|
||||
$permission .= '_other';
|
||||
return $this->permissionManager->hasRolePermission($user, $permission . '_own_profile');
|
||||
}
|
||||
|
||||
$permission .= '_profile';
|
||||
if (!$this->permissionManager->hasRolePermission($user, $permission . '_other_profile')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->permissionManager->hasRolePermission($user, $permission);
|
||||
return $this->permissionManager->checkUserAccess($subject, $user, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
{% if teams is not null %}
|
||||
{% set options = {'teams': teams, 'team': team} %}
|
||||
{% if is_granted('permissions', activity) %}
|
||||
{% set options = options|merge({'route_create': path('activity_team_create', {'id': activity.id}), 'route_edit': path('admin_activity_permissions', {'id': activity.id})}) %}
|
||||
{% set options = options|merge({'route_create': path('post_activity_team', {'id': activity.id}), 'route_edit': path('admin_activity_permissions', {'id': activity.id})}) %}
|
||||
{% endif %}
|
||||
{% if activity.project is not null and (activity.project.teams|length > 0 or activity.project.customer.teams|length > 0) %}
|
||||
{% set options = options|merge({'empty_message': 'team.activity_visibility_inherited'}) %}
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
{% if teams is not null %}
|
||||
{% set options = {'teams': teams, 'team': team} %}
|
||||
{% if is_granted('permissions', customer) %}
|
||||
{% set options = options|merge({'route_create': path('customer_team_create', {'id': customer.id}), 'route_edit': path('admin_customer_permissions', {'id': customer.id})}) %}
|
||||
{% set options = options|merge({'route_create': path('post_customer_team', {'id': customer.id}), 'route_edit': path('admin_customer_permissions', {'id': customer.id})}) %}
|
||||
{% endif %}
|
||||
{{ include('embeds/teams.html.twig', options) }}
|
||||
{% endif %}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<td class="actions">
|
||||
<div class="btn-group">
|
||||
<a href="{{ path(edit_route, {'id': entity.id, 'rate': rate.id}) }}" class="modal-ajax-form btn btn-icon">{{ icon('edit', true) }}</a>
|
||||
<a href="{{ path(delete_route, {'id': entity.id, 'rateId': rate.id}) }}" class="btn btn-icon api-link" data-question="confirm.delete" data-event="kimai.rateUpdate kimai.rateDelete" data-method="DELETE" data-msg-error="action.delete.error">{{ icon('delete', true) }}</a>
|
||||
<a href="#" data-href="{{ path(delete_route, {'id': entity.id, 'rateId': rate.id}) }}" class="btn btn-icon api-link" data-question="confirm.delete" data-event="kimai.rateUpdate kimai.rateDelete" data-method="DELETE" data-msg-error="action.delete.error">{{ icon('delete', true) }}</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -11,7 +11,18 @@
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block box_tools %}
|
||||
{% if route_create is not null and (teams|length == 0 or team is null) and is_granted('create_team') %}
|
||||
{{ widgets.card_tool_button('create', {'title': 'team.create_default', 'translation_domain': 'teams', 'url': route_create}) }}
|
||||
{{ widgets.card_tool_button('create', {
|
||||
'title': 'team.create_default',
|
||||
'translation_domain': 'teams',
|
||||
'url': '#',
|
||||
'class': 'api-link',
|
||||
'attr': {
|
||||
'data-href': route_create,
|
||||
'data-method': 'POST',
|
||||
'data-event': 'kimai.teamUpdate',
|
||||
'data-msg-error': 'action.update.error'
|
||||
}
|
||||
}) }}
|
||||
{% endif %}
|
||||
{% if route_edit is not null %}
|
||||
{{ widgets.card_tool_button('edit', {'class': 'modal-ajax-form open-edit', 'title': 'edit', 'url': route_edit}) }}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="list-group-item">
|
||||
<div class="row align-items-center">
|
||||
<div class="col text-truncate" >
|
||||
<a class="api-link text-decoration-none text-body d-block" href="{{ path('restart_timesheet', {'id': entry.id}) }}"
|
||||
<a class="api-link text-decoration-none text-body d-block" href="#" data-href="{{ path('restart_timesheet', {'id': entry.id}) }}"
|
||||
data-event="kimai.timesheetStart kimai.timesheetUpdate" data-method="PATCH" data-msg-error="timesheet.start.error"
|
||||
data-msg-success="timesheet.start.success" data-bs-dismiss="modal">
|
||||
{{ label_activity(entry.activity) }}
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
{% if teams is not null%}
|
||||
{% set options = {'teams': teams, 'team': team} %}
|
||||
{% if is_granted('permissions', project) %}
|
||||
{% set options = options|merge({'route_create': path('project_team_create', {'id': project.id}), 'route_edit': path('admin_project_permissions', {'id': project.id})}) %}
|
||||
{% set options = options|merge({'route_create': path('post_project_team', {'id': project.id}), 'route_edit': path('admin_project_permissions', {'id': project.id})}) %}
|
||||
{% endif %}
|
||||
{% if project.customer.teams|length > 0 %}
|
||||
{% set options = options|merge({'empty_message': 'team.project_visibility_inherited'}) %}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
{% block wizard_content %}
|
||||
<div class="card-body text-center py-4 p-sm-7">
|
||||
<img src="{{ asset('wizard/done.png') }}" height="120" class="mb-n2" alt="Illustration by Katerina Limpitsouni from https://undraw.co/">
|
||||
<img src="{{ asset('touch-icon-512x512.png') }}" height="120" class="mb-n2" alt="Illustration by Katerina Limpitsouni from https://undraw.co/">
|
||||
<h1 class="mt-5">{{ 'wizard.done.title'|trans({}, 'wizard') }}</h1>
|
||||
<p class="text-body-secondary">{{ 'wizard.done.description'|trans({}, 'wizard') }}</p>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block wizard_content %}
|
||||
<div class="card-body text-center py-4 p-sm-7">
|
||||
<img src="{{ asset('wizard/time-management.png') }}" height="120" class="mb-n2" alt="Illustration by Katerina Limpitsouni from https://undraw.co/">
|
||||
<img src="{{ asset('wizard/time-management.svg') }}" height="120" class="mb-n2" alt="Illustration by Katerina Limpitsouni from https://undraw.co/">
|
||||
<h1 class="mt-5">{{ 'wizard.intro.title'|trans({}, 'wizard') }}</h1>
|
||||
<p class="text-body-secondary">{{ 'wizard.intro.description'|trans({}, 'wizard') }}</p>
|
||||
</div>
|
||||
|
||||
@@ -559,4 +559,49 @@ class ActivityControllerTest extends APIControllerBaseTestCase
|
||||
'message' => 'Not Found'
|
||||
]);
|
||||
}
|
||||
|
||||
public function testPostDefaultTeamAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
$this->request($client, '/api/activities/1/team', 'POST');
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$result = json_decode($content, true);
|
||||
self::assertIsArray($result);
|
||||
self::assertApiResponseTypeStructure('TeamEntity', $result);
|
||||
self::assertIsNumeric($result['id']);
|
||||
$teamId = $result['id'];
|
||||
|
||||
self::assertIsArray($result['members']);
|
||||
self::assertCount(1, $result['members']);
|
||||
self::assertIsArray($result['members'][0]);
|
||||
self::assertArrayHasKey('teamlead', $result['members'][0]);
|
||||
self::assertTrue($result['members'][0]['teamlead']);
|
||||
|
||||
// idempotent
|
||||
$this->request($client, '/api/activities/1/team', 'POST');
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$result = json_decode($content, true);
|
||||
self::assertIsArray($result);
|
||||
self::assertSame($teamId, $result['id']);
|
||||
self::assertIsArray($result['members']);
|
||||
self::assertCount(1, $result['members']);
|
||||
}
|
||||
|
||||
public function testPostDefaultTeamActionIsSecure(): void
|
||||
{
|
||||
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/activities/1/team', 'POST');
|
||||
}
|
||||
|
||||
public function testPostDefaultTeamActionNotFound(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertEntityNotFoundForPost($client, '/api/activities/' . PHP_INT_MAX . '/team');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
|
||||
'/api/activities/{id}/meta',
|
||||
'/api/activities/{id}/rates',
|
||||
'/api/activities/{id}/rates/{rateId}',
|
||||
'/api/activities/{id}/team',
|
||||
'/api/config/timesheet',
|
||||
'/api/config/colors',
|
||||
'/api/customers',
|
||||
@@ -76,6 +77,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
|
||||
'/api/customers/{id}/comments',
|
||||
'/api/customers/{id}/comments/{comment}/pin',
|
||||
'/api/customers/{id}/comments/{comment}',
|
||||
'/api/customers/{id}/team',
|
||||
'/api/export/{id}',
|
||||
'/api/invoices',
|
||||
'/api/invoices/{id}',
|
||||
@@ -89,6 +91,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
|
||||
'/api/projects/{id}/comments',
|
||||
'/api/projects/{id}/comments/{comment}/pin',
|
||||
'/api/projects/{id}/comments/{comment}',
|
||||
'/api/projects/{id}/team',
|
||||
'/api/ping',
|
||||
'/api/version',
|
||||
'/api/plugins',
|
||||
|
||||
@@ -874,4 +874,50 @@ class CustomerControllerTest extends APIControllerBaseTestCase
|
||||
|
||||
self::assertNull($this->getEntityManager()->getRepository(CustomerComment::class)->find($commentId));
|
||||
}
|
||||
|
||||
public function testPostDefaultTeamAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
$this->request($client, '/api/customers/1/team', 'POST');
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$result = json_decode($content, true);
|
||||
self::assertIsArray($result);
|
||||
self::assertApiResponseTypeStructure('TeamEntity', $result);
|
||||
self::assertIsNumeric($result['id']);
|
||||
$teamId = $result['id'];
|
||||
|
||||
// verify customer is bound and current user is teamlead
|
||||
self::assertIsArray($result['members']);
|
||||
self::assertCount(1, $result['members']);
|
||||
self::assertIsArray($result['members'][0]);
|
||||
self::assertArrayHasKey('teamlead', $result['members'][0]);
|
||||
self::assertTrue($result['members'][0]['teamlead']);
|
||||
|
||||
// idempotent: calling again returns the same team without duplicate bindings or members
|
||||
$this->request($client, '/api/customers/1/team', 'POST');
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$result = json_decode($content, true);
|
||||
self::assertIsArray($result);
|
||||
self::assertSame($teamId, $result['id']);
|
||||
self::assertIsArray($result['members']);
|
||||
self::assertCount(1, $result['members']);
|
||||
}
|
||||
|
||||
public function testPostDefaultTeamActionIsSecure(): void
|
||||
{
|
||||
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/customers/1/team', 'POST');
|
||||
}
|
||||
|
||||
public function testPostDefaultTeamActionNotFound(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertEntityNotFoundForPost($client, '/api/customers/' . PHP_INT_MAX . '/team');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -983,4 +983,49 @@ class ProjectControllerTest extends APIControllerBaseTestCase
|
||||
|
||||
self::assertNull($this->getEntityManager()->getRepository(ProjectComment::class)->find($commentId));
|
||||
}
|
||||
|
||||
public function testPostDefaultTeamAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
$this->request($client, '/api/projects/1/team', 'POST');
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$result = json_decode($content, true);
|
||||
self::assertIsArray($result);
|
||||
self::assertApiResponseTypeStructure('TeamEntity', $result);
|
||||
self::assertIsNumeric($result['id']);
|
||||
$teamId = $result['id'];
|
||||
|
||||
self::assertIsArray($result['members']);
|
||||
self::assertCount(1, $result['members']);
|
||||
self::assertIsArray($result['members'][0]);
|
||||
self::assertArrayHasKey('teamlead', $result['members'][0]);
|
||||
self::assertTrue($result['members'][0]['teamlead']);
|
||||
|
||||
// idempotent
|
||||
$this->request($client, '/api/projects/1/team', 'POST');
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$result = json_decode($content, true);
|
||||
self::assertIsArray($result);
|
||||
self::assertSame($teamId, $result['id']);
|
||||
self::assertIsArray($result['members']);
|
||||
self::assertCount(1, $result['members']);
|
||||
}
|
||||
|
||||
public function testPostDefaultTeamActionIsSecure(): void
|
||||
{
|
||||
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/projects/1/team', 'POST');
|
||||
}
|
||||
|
||||
public function testPostDefaultTeamActionNotFound(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertEntityNotFoundForPost($client, '/api/projects/' . PHP_INT_MAX . '/team');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,16 @@
|
||||
|
||||
namespace App\Tests\API;
|
||||
|
||||
use App\DataFixtures\UserFixtures;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Role;
|
||||
use App\Entity\RolePermission;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\User;
|
||||
use App\Tests\DataFixtures\TeamFixtures;
|
||||
use App\User\PermissionService;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
@@ -778,4 +785,334 @@ class TeamControllerTest extends APIControllerBaseTestCase
|
||||
// cannot remove activity
|
||||
$this->assertBadRequest($client, '/api/teams/' . $result['id'] . '/activities/1', 'DELETE');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up tony_teamlead so that he has the `edit_team` permission via a
|
||||
* dedicated test role, and makes him the teamlead of a fresh team.
|
||||
*
|
||||
* This simulates an installation that lets teamleads manage their own
|
||||
* teams. The permission is routed through PermissionService so the shared
|
||||
* cache is invalidated and the request kernel sees the new permission.
|
||||
*
|
||||
* @return Team the team the attacker is teamlead of
|
||||
*/
|
||||
private function prepareAttackerTeamleadWithEditTeam(string $suffix): Team
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
$roleName = 'TEST_EDIT_TEAM_' . $suffix;
|
||||
$role = (new Role())->setName($roleName);
|
||||
$permission = (new RolePermission())->setRole($role)->setPermission('edit_team')->setAllowed(true);
|
||||
$em->persist($role);
|
||||
$p = self::getContainer()->get(PermissionService::class);
|
||||
self::assertInstanceOf(PermissionService::class, $p);
|
||||
$p->saveRolePermission($permission);
|
||||
|
||||
$attacker = $this->getUserByName(UserFixtures::USERNAME_TEAMLEAD);
|
||||
$attacker->addRole($roleName);
|
||||
$em->persist($attacker);
|
||||
|
||||
$attackerTeam = new Team('GHSA-xv4r attacker team ' . $suffix);
|
||||
$attackerTeam->addTeamlead($attacker);
|
||||
$em->persist($attackerTeam);
|
||||
|
||||
$em->flush();
|
||||
|
||||
return $attackerTeam;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for GHSA-xv4r-4885-gwpg.
|
||||
*
|
||||
* A teamlead with edit_team permission must not be able to add a user
|
||||
* that falls outside their authorized management scope by calling the
|
||||
* member-assignment API directly. The frontend hides those users; the
|
||||
* backend has to enforce the same boundary.
|
||||
*/
|
||||
public function testPostMemberActionDeniesUserOutsideTeamleadScope(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
$attackerTeam = $this->prepareAttackerTeamleadWithEditTeam('GHSA_XV4R_MEMBER');
|
||||
|
||||
// target user is in a separate team that the attacker has no role in,
|
||||
// and the target is not a "regular-user-only without any teams" (which
|
||||
// would otherwise be visible to any teamlead).
|
||||
$target = $this->getUserByName(UserFixtures::USERNAME_USER);
|
||||
$isolatedTeam = new Team('GHSA-xv4r isolated team');
|
||||
$isolatedTeam->addUser($target);
|
||||
$isolatedTeam->addTeamlead($this->getUserByRole(User::ROLE_SUPER_ADMIN));
|
||||
$em->persist($isolatedTeam);
|
||||
$em->flush();
|
||||
|
||||
$teamId = $attackerTeam->getId();
|
||||
$targetId = $target->getId();
|
||||
self::assertIsInt($teamId);
|
||||
self::assertIsInt($targetId);
|
||||
|
||||
$this->request($client, '/api/teams/' . $teamId . '/members/' . $targetId, 'POST');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
// verify the relation was NOT persisted
|
||||
$em->clear();
|
||||
$reloaded = $em->getRepository(Team::class)->find($teamId);
|
||||
self::assertInstanceOf(Team::class, $reloaded);
|
||||
self::assertFalse($reloaded->hasUser($target));
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for GHSA-xv4r-4885-gwpg.
|
||||
*
|
||||
* The teamlead must not be able to attach an activity that they cannot
|
||||
* view in the first place, even when they may edit the team.
|
||||
*/
|
||||
public function testPostActivityActionDeniesActivityOutsideTeamleadScope(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
$attackerTeam = $this->prepareAttackerTeamleadWithEditTeam('GHSA_XV4R_ACTIVITY');
|
||||
|
||||
// activity is created without any team relation that the attacker is part of
|
||||
$customer = new Customer('GHSA-xv4r activity customer');
|
||||
$customer->setCountry('DE');
|
||||
$customer->setTimezone('Europe/Berlin');
|
||||
$em->persist($customer);
|
||||
|
||||
$project = new Project();
|
||||
$project->setName('GHSA-xv4r activity project');
|
||||
$project->setCustomer($customer);
|
||||
$em->persist($project);
|
||||
|
||||
$activity = new Activity();
|
||||
$activity->setName('GHSA-xv4r out-of-scope activity');
|
||||
$activity->setProject($project);
|
||||
$em->persist($activity);
|
||||
|
||||
$em->flush();
|
||||
|
||||
$teamId = $attackerTeam->getId();
|
||||
$activityId = $activity->getId();
|
||||
self::assertIsInt($teamId);
|
||||
self::assertIsInt($activityId);
|
||||
|
||||
$this->request($client, '/api/teams/' . $teamId . '/activities/' . $activityId, 'POST');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
$em->clear();
|
||||
$reloaded = $em->getRepository(Team::class)->find($teamId);
|
||||
self::assertInstanceOf(Team::class, $reloaded);
|
||||
$reloadedActivity = $em->getRepository(Activity::class)->find($activityId);
|
||||
self::assertInstanceOf(Activity::class, $reloadedActivity);
|
||||
self::assertFalse($reloaded->hasActivity($reloadedActivity));
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for GHSA-xv4r-4885-gwpg (postCustomerAction variant).
|
||||
*
|
||||
* A teamlead with edit_team permission must not be able to grant their
|
||||
* team access to a customer that they cannot view themselves. The bug
|
||||
* pattern is identical to the postActivityAction variant.
|
||||
*/
|
||||
public function testPostCustomerActionDeniesCustomerOutsideTeamleadScope(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
$attackerTeam = $this->prepareAttackerTeamleadWithEditTeam('GHSA_XV4R_CUSTOMER');
|
||||
|
||||
// customer has no team relation to the attacker -> attacker has no view permission on it
|
||||
$customer = new Customer('GHSA-xv4r out-of-scope customer');
|
||||
$customer->setCountry('DE');
|
||||
$customer->setTimezone('Europe/Berlin');
|
||||
$em->persist($customer);
|
||||
$em->flush();
|
||||
|
||||
$teamId = $attackerTeam->getId();
|
||||
$customerId = $customer->getId();
|
||||
self::assertIsInt($teamId);
|
||||
self::assertIsInt($customerId);
|
||||
|
||||
$this->request($client, '/api/teams/' . $teamId . '/customers/' . $customerId, 'POST');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
$em->clear();
|
||||
$reloaded = $em->getRepository(Team::class)->find($teamId);
|
||||
self::assertInstanceOf(Team::class, $reloaded);
|
||||
$reloadedCustomer = $em->getRepository(Customer::class)->find($customerId);
|
||||
self::assertInstanceOf(Customer::class, $reloadedCustomer);
|
||||
self::assertFalse($reloaded->hasCustomer($reloadedCustomer));
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for GHSA-xv4r-4885-gwpg (postProjectAction variant).
|
||||
*
|
||||
* A teamlead with edit_team permission must not be able to grant their
|
||||
* team access to a project that they cannot view themselves.
|
||||
*/
|
||||
public function testPostProjectActionDeniesProjectOutsideTeamleadScope(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
$attackerTeam = $this->prepareAttackerTeamleadWithEditTeam('GHSA_XV4R_PROJECT');
|
||||
|
||||
$customer = new Customer('GHSA-xv4r project customer');
|
||||
$customer->setCountry('DE');
|
||||
$customer->setTimezone('Europe/Berlin');
|
||||
$em->persist($customer);
|
||||
|
||||
$project = new Project();
|
||||
$project->setName('GHSA-xv4r out-of-scope project');
|
||||
$project->setCustomer($customer);
|
||||
$em->persist($project);
|
||||
$em->flush();
|
||||
|
||||
$teamId = $attackerTeam->getId();
|
||||
$projectId = $project->getId();
|
||||
self::assertIsInt($teamId);
|
||||
self::assertIsInt($projectId);
|
||||
|
||||
$this->request($client, '/api/teams/' . $teamId . '/projects/' . $projectId, 'POST');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
$em->clear();
|
||||
$reloaded = $em->getRepository(Team::class)->find($teamId);
|
||||
self::assertInstanceOf(Team::class, $reloaded);
|
||||
$reloadedProject = $em->getRepository(Project::class)->find($projectId);
|
||||
self::assertInstanceOf(Project::class, $reloadedProject);
|
||||
self::assertFalse($reloaded->hasProject($reloadedProject));
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for GHSA-xv4r-4885-gwpg (patchAction variant).
|
||||
*
|
||||
* The PATCH /api/teams/{id} endpoint takes a `members` array and replaces
|
||||
* the team's membership. A teamlead with edit_team permission must not be
|
||||
* able to attach an out-of-scope user this way.
|
||||
*/
|
||||
public function testPatchActionDeniesAddingOutOfScopeMember(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
$attackerTeam = $this->prepareAttackerTeamleadWithEditTeam('GHSA_XV4R_PATCH');
|
||||
|
||||
$attacker = $this->getUserByName(UserFixtures::USERNAME_TEAMLEAD);
|
||||
$attackerId = $attacker->getId();
|
||||
self::assertIsInt($attackerId);
|
||||
|
||||
// target user kept out of attacker's reach
|
||||
$target = $this->getUserByName(UserFixtures::USERNAME_USER);
|
||||
$isolatedTeam = new Team('GHSA-xv4r isolated team patch');
|
||||
$isolatedTeam->addUser($target);
|
||||
$isolatedTeam->addTeamlead($this->getUserByRole(User::ROLE_SUPER_ADMIN));
|
||||
$em->persist($isolatedTeam);
|
||||
$em->flush();
|
||||
|
||||
$teamId = $attackerTeam->getId();
|
||||
$targetId = $target->getId();
|
||||
self::assertIsInt($teamId);
|
||||
self::assertIsInt($targetId);
|
||||
|
||||
$payload = [
|
||||
'name' => 'GHSA-xv4r patch team',
|
||||
'members' => [
|
||||
['user' => $attackerId, 'teamlead' => true],
|
||||
['user' => $targetId, 'teamlead' => false],
|
||||
],
|
||||
];
|
||||
|
||||
$this->request($client, '/api/teams/' . $teamId, 'PATCH', [], json_encode($payload));
|
||||
|
||||
$response = $client->getResponse();
|
||||
// either a hard 403 or a validation rejection of the members field is acceptable;
|
||||
// any 2xx that ends with the target attached to the team is the security failure.
|
||||
self::assertFalse(
|
||||
$response->isSuccessful() && str_contains((string) $response->getContent(), '"id"'),
|
||||
'PATCH /api/teams must not silently attach an out-of-scope user via the members array.'
|
||||
);
|
||||
|
||||
$em->clear();
|
||||
$reloaded = $em->getRepository(Team::class)->find($teamId);
|
||||
self::assertInstanceOf(Team::class, $reloaded);
|
||||
self::assertFalse(
|
||||
$reloaded->hasUser($target),
|
||||
'Out-of-scope user must not have been added to the team via PATCH.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for GHSA-xv4r-4885-gwpg (postAction variant).
|
||||
*
|
||||
* The POST /api/teams endpoint accepts a `members` array. A user whose
|
||||
* role grants `create_team` but not `view_all_data` must not be able to
|
||||
* create a team with members they cannot manage. This covers the
|
||||
* non-admin "team creator" role configuration.
|
||||
*/
|
||||
public function testPostActionDeniesCreatingTeamWithOutOfScopeMember(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
// grant create_team to a custom role and attach it to tony_teamlead
|
||||
$roleName = 'TEST_CREATE_TEAM_GHSA_XV4R';
|
||||
$role = (new Role())->setName($roleName);
|
||||
$permission = (new RolePermission())->setRole($role)->setPermission('create_team')->setAllowed(true);
|
||||
$em->persist($role);
|
||||
$p = self::getContainer()->get(PermissionService::class);
|
||||
self::assertInstanceOf(PermissionService::class, $p);
|
||||
$p->saveRolePermission($permission);
|
||||
|
||||
$attacker = $this->getUserByName(UserFixtures::USERNAME_TEAMLEAD);
|
||||
$attacker->addRole($roleName);
|
||||
$em->persist($attacker);
|
||||
|
||||
$attackerId = $attacker->getId();
|
||||
self::assertIsInt($attackerId);
|
||||
|
||||
// target user is unreachable for the attacker
|
||||
$target = $this->getUserByName(UserFixtures::USERNAME_USER);
|
||||
$isolatedTeam = new Team('GHSA-xv4r isolated team create');
|
||||
$isolatedTeam->addUser($target);
|
||||
$isolatedTeam->addTeamlead($this->getUserByRole(User::ROLE_SUPER_ADMIN));
|
||||
$em->persist($isolatedTeam);
|
||||
$em->flush();
|
||||
|
||||
$targetId = $target->getId();
|
||||
self::assertIsInt($targetId);
|
||||
|
||||
$payload = [
|
||||
'name' => 'GHSA-xv4r created team',
|
||||
'members' => [
|
||||
['user' => $attackerId, 'teamlead' => true],
|
||||
['user' => $targetId, 'teamlead' => false],
|
||||
],
|
||||
];
|
||||
|
||||
$this->request($client, '/api/teams', 'POST', [], json_encode($payload));
|
||||
|
||||
$response = $client->getResponse();
|
||||
$body = (string) $response->getContent();
|
||||
|
||||
// success body would contain the new id and the target as a member -> security failure
|
||||
if ($response->isSuccessful()) {
|
||||
$decoded = json_decode($body, true);
|
||||
self::assertIsArray($decoded);
|
||||
$memberIds = [];
|
||||
if (\is_array($decoded['members'] ?? null)) {
|
||||
foreach ($decoded['members'] as $entry) {
|
||||
if (\is_array($entry) && \is_array($entry['user'] ?? null) && isset($entry['user']['id'])) {
|
||||
$memberIds[] = $entry['user']['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
self::assertNotContains(
|
||||
$targetId,
|
||||
$memberIds,
|
||||
'POST /api/teams must not silently accept an out-of-scope user in the members array.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1528,6 +1528,72 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
$this->assertEntityNotFoundForPatch(User::ROLE_ADMIN, '/api/timesheets/11/duplicate', []);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// GHSA-c6w6-57jj-62vh — restart/duplicate after project access revocation.
|
||||
//
|
||||
// "restart" and "duplicate" derive a NEW timesheet from a historical
|
||||
// entry the user still owns. Once the user's team access to the underlying
|
||||
// project/activity is revoked, neither operation may create a new record
|
||||
// under it. The data write itself is already blocked by
|
||||
// TimesheetTeamAccessValidator (since 2.57); these tests additionally pin
|
||||
// that the TimesheetVoter denies the request at the authorization layer —
|
||||
// a clean 403, not an incidental 400 from downstream validation.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public function testRestartAndDuplicateDeniedAfterProjectAccessRevoked(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$em = $this->getEntityManager();
|
||||
$owner = $this->getUserByRole(User::ROLE_USER);
|
||||
|
||||
// The customer is restricted to a team the user is NOT a member of:
|
||||
// the user's access to this project/activity has been revoked, but
|
||||
// their historical timesheet still references it.
|
||||
$revokedTeam = new Team('GHSA-c6w6 team without access');
|
||||
$em->persist($revokedTeam);
|
||||
|
||||
$timesheet = $this->persistRestrictedTimesheet($owner, [$revokedTeam], running: false);
|
||||
$id = $timesheet->getId();
|
||||
self::assertIsInt($id);
|
||||
|
||||
$before = $this->getEntityManager()->getRepository(Timesheet::class)->count([]);
|
||||
|
||||
// PATCH + GET .../restart
|
||||
$this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
// PATCH .../duplicate
|
||||
$this->request($client, '/api/timesheets/' . $id . '/duplicate', 'PATCH');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
// No new record may have been persisted under the revoked project.
|
||||
$after = $this->getEntityManager()->getRepository(Timesheet::class)->count([]);
|
||||
self::assertSame($before, $after, 'restart/duplicate leaked through and created a new timesheet under the revoked project');
|
||||
}
|
||||
|
||||
public function testRestartAndDuplicateAllowedWhenUserStillHasProjectAccess(): void
|
||||
{
|
||||
// Positive control: as long as the user still has team access to the
|
||||
// project/activity, restart and duplicate keep working.
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$em = $this->getEntityManager();
|
||||
$owner = $this->getUserByRole(User::ROLE_USER);
|
||||
|
||||
$team = new Team('GHSA-c6w6 team with access');
|
||||
$team->addUser($owner);
|
||||
$em->persist($team);
|
||||
|
||||
$timesheet = $this->persistRestrictedTimesheet($owner, [$team], running: false);
|
||||
$id = $timesheet->getId();
|
||||
self::assertIsInt($id);
|
||||
|
||||
$this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH');
|
||||
self::assertTrue($client->getResponse()->isSuccessful(), 'restart must succeed while the user still has project access');
|
||||
|
||||
$this->request($client, '/api/timesheets/' . $id . '/duplicate', 'PATCH');
|
||||
self::assertTrue($client->getResponse()->isSuccessful(), 'duplicate must succeed while the user still has project access');
|
||||
}
|
||||
|
||||
public function testExportAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
@@ -1695,17 +1761,17 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
$this->request($client, '/api/timesheets/' . $id, 'PATCH', [], $patch);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
// 3) PATCH /api/timesheets/{id}/stop and 4) GET .../stop
|
||||
// 3) PATCH /api/timesheets/{id}/stop is access-denied; 4) GET .../stop is no longer routable
|
||||
$this->request($client, '/api/timesheets/' . $id . '/stop', 'PATCH');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
$this->request($client, '/api/timesheets/' . $id . '/stop', 'GET');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
self::assertEquals(Response::HTTP_METHOD_NOT_ALLOWED, $client->getResponse()->getStatusCode());
|
||||
|
||||
// 5) PATCH /api/timesheets/{id}/restart and 6) GET .../restart
|
||||
// 5) PATCH /api/timesheets/{id}/restart is access-denied; 6) GET .../restart is no longer routable
|
||||
$this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
$this->request($client, '/api/timesheets/' . $id . '/restart', 'GET');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
self::assertEquals(Response::HTTP_METHOD_NOT_ALLOWED, $client->getResponse()->getStatusCode());
|
||||
|
||||
// 7) PATCH /api/timesheets/{id}/duplicate
|
||||
$this->request($client, '/api/timesheets/' . $id . '/duplicate', 'PATCH');
|
||||
@@ -1931,7 +1997,7 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
$this->request($client, '/api/timesheets/' . $id . '/stop', 'GET');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
self::assertEquals(Response::HTTP_METHOD_NOT_ALLOWED, $client->getResponse()->getStatusCode());
|
||||
|
||||
// Confirm side-effect-free: timesheet must still be running.
|
||||
$em->clear();
|
||||
|
||||
@@ -350,22 +350,6 @@ class ActivityControllerTest extends AbstractControllerBaseTestCase
|
||||
self::assertEquals(2, $activity->getTeams()->count());
|
||||
}
|
||||
|
||||
public function testCreateDefaultTeamAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertAccessIsGranted($client, '/admin/activity/1/details');
|
||||
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body');
|
||||
self::assertStringContainsString('Visible to everyone, as no team was assigned yet.', $node->text());
|
||||
|
||||
$this->request($client, '/admin/activity/1/create_team');
|
||||
$this->assertIsRedirect($client, $this->createUrl('/admin/activity/1/details'));
|
||||
$client->followRedirect();
|
||||
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-title');
|
||||
self::assertStringContainsString('Only visible to the following teams and all admins.', $node->text());
|
||||
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body table tbody tr');
|
||||
self::assertEquals(1, $node->count());
|
||||
}
|
||||
|
||||
public function testDeleteAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
@@ -216,22 +216,6 @@ class CustomerControllerTest extends AbstractControllerBaseTestCase
|
||||
self::assertStringContainsString('<p>A beautiful and short comment <strong>with some</strong> markdown formatting</p>', $node->html());
|
||||
}
|
||||
|
||||
public function testCreateDefaultTeamAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertAccessIsGranted($client, '/admin/customer/1/details');
|
||||
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body');
|
||||
self::assertStringContainsString('Visible to everyone, as no team was assigned yet.', $node->text(null, true));
|
||||
|
||||
$this->request($client, '/admin/customer/1/create_team');
|
||||
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
|
||||
$client->followRedirect();
|
||||
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-title');
|
||||
self::assertStringContainsString('Only visible to the following teams and all admins.', $node->text(null, true));
|
||||
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body table tbody tr');
|
||||
self::assertEquals(1, $node->count());
|
||||
}
|
||||
|
||||
public function testProjectsAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
@@ -265,6 +265,24 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
|
||||
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/export/template-create');
|
||||
}
|
||||
|
||||
public function testCreateTemplateIsSecureForTeamlead(): void
|
||||
{
|
||||
// GHSA-rw46-qg69-vg6h
|
||||
$this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/export/template-create');
|
||||
}
|
||||
|
||||
public function testEditTemplateIsSecureForTeamlead(): void
|
||||
{
|
||||
// GHSA-rw46-qg69-vg6h
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
/** @var ExportTemplate[] $templates */
|
||||
$templates = $this->importFixture(new ExportTemplateFixtures());
|
||||
$id = $templates[0]->getId();
|
||||
|
||||
$this->request($client, $this->createUrl('/export/template-edit/' . $id));
|
||||
$this->assertAccessDenied($client);
|
||||
}
|
||||
|
||||
public function testCreateTemplateAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
@@ -40,7 +40,8 @@ class FavoriteControllerTest extends AbstractControllerBaseTestCase
|
||||
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertNotFalse($content);
|
||||
self::assertStringContainsString('<a class="api-link text-decoration-none text-body d-block" href="/api/timesheets/', $content);
|
||||
self::assertStringContainsString('<a class="api-link text-decoration-none text-body d-block" href="#', $content);
|
||||
self::assertStringContainsString('data-href="/api/timesheets/', $content);
|
||||
self::assertStringContainsString('data-event="kimai.timesheetStart kimai.timesheetUpdate" data-method="PATCH" data-msg-error="timesheet', $content);
|
||||
}
|
||||
|
||||
|
||||
@@ -331,22 +331,6 @@ class ProjectControllerTest extends AbstractControllerBaseTestCase
|
||||
self::assertStringContainsString('<p>A beautiful and long comment <strong>with some</strong> markdown formatting</p>', $node->html());
|
||||
}
|
||||
|
||||
public function testCreateDefaultTeamAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertAccessIsGranted($client, '/admin/project/1/details');
|
||||
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body');
|
||||
self::assertStringContainsString('Visible to everyone, as no team was assigned yet.', $node->text(null, true));
|
||||
|
||||
$this->request($client, '/admin/project/1/create_team');
|
||||
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
|
||||
$client->followRedirect();
|
||||
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-title');
|
||||
self::assertStringContainsString('Only visible to the following teams and all admins.', $node->text(null, true));
|
||||
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body table tbody tr');
|
||||
self::assertEquals(1, $node->count());
|
||||
}
|
||||
|
||||
public function testActivitiesAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
216
tests/EventSubscriber/PasswordResetSubscriberTest.php
Normal file
216
tests/EventSubscriber/PasswordResetSubscriberTest.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\EventSubscriber;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\EventSubscriber\PasswordResetSubscriber;
|
||||
use App\EventSubscriber\WizardSubscriber;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
#[CoversClass(PasswordResetSubscriber::class)]
|
||||
class PasswordResetSubscriberTest extends TestCase
|
||||
{
|
||||
public function testGetSubscribedEvents(): void
|
||||
{
|
||||
self::assertEquals([KernelEvents::REQUEST => ['onKernelRequest', -20]], PasswordResetSubscriber::getSubscribedEvents());
|
||||
}
|
||||
|
||||
public function testPasswordResetHasHigherPriorityThanWizardSubscriber(): void
|
||||
{
|
||||
self::assertGreaterThan(
|
||||
WizardSubscriber::getSubscribedEvents()[KernelEvents::REQUEST][1],
|
||||
PasswordResetSubscriber::getSubscribedEvents()[KernelEvents::REQUEST][1]
|
||||
);
|
||||
}
|
||||
|
||||
public function testOnKernelRequestIgnoresSubRequest(): void
|
||||
{
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->never())->method('getToken');
|
||||
|
||||
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
|
||||
$event = $this->createRequestEvent('/dashboard', false);
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
public function testOnKernelRequestIgnoresMissingToken(): void
|
||||
{
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$security->expects($this->never())->method('isGranted');
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn(null);
|
||||
|
||||
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<array{string}>
|
||||
*/
|
||||
public static function provideExcludedUris(): iterable
|
||||
{
|
||||
yield ['/api/timesheets'];
|
||||
yield ['/register/new'];
|
||||
yield ['/wizard/intro'];
|
||||
}
|
||||
|
||||
#[DataProvider('provideExcludedUris')]
|
||||
public function testOnKernelRequestIgnoresExcludedUris(string $uri): void
|
||||
{
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->expects($this->never())->method('getUser');
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$security->expects($this->never())->method('isGranted');
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
|
||||
$event = $this->createRequestEvent($uri);
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
public function testOnKernelRequestIgnoresNonUserToken(): void
|
||||
{
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->expects($this->once())->method('getUser')->willReturn($this->createMock(UserInterface::class));
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$security->expects($this->never())->method('isGranted');
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
public function testOnKernelRequestIgnoresUserWithoutFullAuthentication(): void
|
||||
{
|
||||
$user = new User();
|
||||
$token = $this->createUserToken($user);
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(false);
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
public function testOnKernelRequestIgnoresUserWithoutPasswordReset(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setEnabled(true);
|
||||
$token = $this->createUserToken($user);
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator->expects($this->never())->method('generate');
|
||||
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(true);
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
public function testOnKernelRequestRedirectsToPasswordWizard(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setEnabled(true);
|
||||
$user->setRequiresPasswordReset();
|
||||
$token = $this->createUserToken($user);
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator
|
||||
->expects($this->once())
|
||||
->method('generate')
|
||||
->with('wizard', ['wizard' => 'password'])
|
||||
->willReturn('/wizard/password');
|
||||
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(true);
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
self::assertSame('/wizard/password', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
private function createUserToken(User $user): TokenInterface
|
||||
{
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->expects($this->once())->method('getUser')->willReturn($user);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function createRequestEvent(string $uri, bool $mainRequest = true): RequestEvent
|
||||
{
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$request = Request::create($uri);
|
||||
|
||||
return new RequestEvent($kernel, $request, $mainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
|
||||
}
|
||||
}
|
||||
@@ -10,31 +10,152 @@
|
||||
namespace App\Tests\EventSubscriber;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use App\Entity\User;
|
||||
use App\EventSubscriber\RedirectToLocaleSubscriber;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
|
||||
#[CoversClass(RedirectToLocaleSubscriber::class)]
|
||||
class RedirectToLocaleSubscriberTest extends TestCase
|
||||
{
|
||||
public function testConstruct(): void
|
||||
public function testGetSubscribedEvents(): void
|
||||
{
|
||||
self::assertEquals([KernelEvents::REQUEST => ['onKernelRequest', 0]], RedirectToLocaleSubscriber::getSubscribedEvents());
|
||||
}
|
||||
|
||||
public function testOnKernelRequestIgnoresNonHomepageRequest(): void
|
||||
{
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->never())->method('getToken');
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$sut = new RedirectToLocaleSubscriber($urlGenerator, new LocaleService(['de' => LocaleService::DEFAULT_SETTINGS, 'en' => LocaleService::DEFAULT_SETTINGS]));
|
||||
$urlGenerator->expects($this->never())->method('generate');
|
||||
|
||||
self::assertEquals([KernelEvents::REQUEST => ['onKernelRequest']], RedirectToLocaleSubscriber::getSubscribedEvents());
|
||||
|
||||
$request = $this->createMock(Request::class);
|
||||
$request->expects($this->once())->method('getPathInfo')->willReturn('/de');
|
||||
|
||||
$event = $this->createMock(RequestEvent::class);
|
||||
$event->expects($this->once())->method('getRequest')->willReturn($request);
|
||||
$event->expects($this->never())->method('setResponse');
|
||||
$sut = new RedirectToLocaleSubscriber($urlGenerator, $this->createLocaleService(), $storage);
|
||||
$event = $this->createRequestEvent('/de');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
public function testOnKernelRequestIgnoresHomepageWithSameHostReferer(): void
|
||||
{
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->never())->method('getToken');
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator->expects($this->never())->method('generate');
|
||||
|
||||
$sut = new RedirectToLocaleSubscriber($urlGenerator, $this->createLocaleService(), $storage);
|
||||
$event = $this->createRequestEvent('/', ['referer' => 'https://www.kimai.test/de/dashboard']);
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
public function testOnKernelRequestRedirectsAuthenticatedUserToLanguage(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setLanguage('fr');
|
||||
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->expects($this->once())->method('getUser')->willReturn($user);
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator
|
||||
->expects($this->once())
|
||||
->method('generate')
|
||||
->with('homepage', ['_locale' => 'fr'])
|
||||
->willReturn('/fr');
|
||||
|
||||
$sut = new RedirectToLocaleSubscriber($urlGenerator, $this->createLocaleService(), $storage);
|
||||
$event = $this->createRequestEvent('/');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
self::assertSame('/fr', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testOnKernelRequestRedirectsAnonymousUserToPreferredBrowserLanguage(): void
|
||||
{
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn(null);
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator
|
||||
->expects($this->once())
|
||||
->method('generate')
|
||||
->with('homepage', ['_locale' => 'de'])
|
||||
->willReturn('/de');
|
||||
|
||||
$sut = new RedirectToLocaleSubscriber($urlGenerator, $this->createLocaleService(), $storage);
|
||||
$event = $this->createRequestEvent('/', ['Accept-Language' => 'de-DE,de;q=0.9,en;q=0.8']);
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
self::assertSame('/de', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testOnKernelRequestFallsBackToDefaultLocaleForAnonymousUser(): void
|
||||
{
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn(null);
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator
|
||||
->expects($this->once())
|
||||
->method('generate')
|
||||
->with('homepage', ['_locale' => 'en'])
|
||||
->willReturn('/en');
|
||||
|
||||
$sut = new RedirectToLocaleSubscriber($urlGenerator, $this->createLocaleService(), $storage);
|
||||
$event = $this->createRequestEvent('/', ['Accept-Language' => 'es-ES,es;q=0.9']);
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
self::assertSame('/en', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
private function createLocaleService(): LocaleService
|
||||
{
|
||||
return new LocaleService([
|
||||
'de' => [...LocaleService::DEFAULT_SETTINGS, 'translation' => true],
|
||||
'en' => [...LocaleService::DEFAULT_SETTINGS, 'translation' => true],
|
||||
'fr' => [...LocaleService::DEFAULT_SETTINGS, 'translation' => true],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
private function createRequestEvent(string $uri, array $headers = []): RequestEvent
|
||||
{
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$request = Request::create($uri, 'GET', [], [], [], ['HTTP_HOST' => 'www.kimai.test', 'HTTPS' => 'on']);
|
||||
|
||||
foreach ($headers as $name => $value) {
|
||||
$request->headers->set($name, $value);
|
||||
}
|
||||
|
||||
return new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
145
tests/EventSubscriber/UserEnvironmentSubscriberTest.php
Normal file
145
tests/EventSubscriber/UserEnvironmentSubscriberTest.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\EventSubscriber;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use App\Entity\User;
|
||||
use App\EventSubscriber\UserEnvironmentSubscriber;
|
||||
use App\Twig\LocaleFormatExtensions;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\FinishRequestEvent;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
#[CoversClass(UserEnvironmentSubscriber::class)]
|
||||
class UserEnvironmentSubscriberTest extends TestCase
|
||||
{
|
||||
private string $defaultLocale;
|
||||
private string $defaultTimezone;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->defaultLocale = \Locale::getDefault();
|
||||
$this->defaultTimezone = date_default_timezone_get();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
\Locale::setDefault($this->defaultLocale);
|
||||
date_default_timezone_set($this->defaultTimezone);
|
||||
}
|
||||
|
||||
public function testGetSubscribedEvents(): void
|
||||
{
|
||||
self::assertEquals([
|
||||
KernelEvents::REQUEST => ['prepareEnvironment', -10],
|
||||
KernelEvents::FINISH_REQUEST => ['restoreLocale', -20],
|
||||
], UserEnvironmentSubscriber::getSubscribedEvents());
|
||||
}
|
||||
|
||||
public function testPrepareEnvironmentUsesRequestLocaleWithoutAuthenticatedUser(): void
|
||||
{
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn(null);
|
||||
|
||||
$auth = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$auth->expects($this->never())->method('isGranted');
|
||||
|
||||
$localeExtension = $this->createLocaleFormatExtensions();
|
||||
$sut = new UserEnvironmentSubscriber($storage, $auth, $localeExtension);
|
||||
|
||||
$sut->prepareEnvironment($this->createRequestEvent('fr', true));
|
||||
|
||||
self::assertSame('fr', \Locale::getDefault());
|
||||
self::assertSame('fr', $localeExtension->getLocale());
|
||||
self::assertSame($this->defaultTimezone, date_default_timezone_get());
|
||||
}
|
||||
|
||||
public function testPrepareEnvironmentUsesUserLocaleTimezoneAndPermission(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setLocale('de');
|
||||
$user->setTimezone('Europe/Berlin');
|
||||
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->expects($this->once())->method('getUser')->willReturn($user);
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$auth = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$auth->expects($this->once())->method('isGranted')->with('view_all_data')->willReturn(true);
|
||||
|
||||
$localeExtension = $this->createLocaleFormatExtensions();
|
||||
$sut = new UserEnvironmentSubscriber($storage, $auth, $localeExtension);
|
||||
|
||||
$sut->prepareEnvironment($this->createRequestEvent('en', true));
|
||||
|
||||
self::assertSame('de', \Locale::getDefault());
|
||||
self::assertSame('de', $localeExtension->getLocale());
|
||||
self::assertSame('Europe/Berlin', date_default_timezone_get());
|
||||
self::assertTrue($user->canSeeAllData());
|
||||
}
|
||||
|
||||
public function testRestoreLocaleAfterSubRequest(): void
|
||||
{
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn(null);
|
||||
|
||||
$auth = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$auth->expects($this->never())->method('isGranted');
|
||||
|
||||
$localeExtension = $this->createLocaleFormatExtensions();
|
||||
$sut = new UserEnvironmentSubscriber($storage, $auth, $localeExtension);
|
||||
|
||||
$sut->prepareEnvironment($this->createRequestEvent('de', true));
|
||||
|
||||
\Locale::setDefault('it');
|
||||
$localeExtension->setLocale('it');
|
||||
|
||||
$sut->restoreLocale($this->createFinishRequestEvent(false));
|
||||
|
||||
self::assertSame('de', \Locale::getDefault());
|
||||
self::assertSame('de', $localeExtension->getLocale());
|
||||
}
|
||||
|
||||
private function createLocaleFormatExtensions(): LocaleFormatExtensions
|
||||
{
|
||||
return new LocaleFormatExtensions(new LocaleService([
|
||||
'de' => LocaleService::DEFAULT_SETTINGS,
|
||||
'en' => LocaleService::DEFAULT_SETTINGS,
|
||||
'fr' => LocaleService::DEFAULT_SETTINGS,
|
||||
'it' => LocaleService::DEFAULT_SETTINGS,
|
||||
]));
|
||||
}
|
||||
|
||||
private function createRequestEvent(string $locale, bool $mainRequest): RequestEvent
|
||||
{
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$request = new Request();
|
||||
$request->setLocale($locale);
|
||||
|
||||
return new RequestEvent($kernel, $request, $mainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
|
||||
}
|
||||
|
||||
private function createFinishRequestEvent(bool $mainRequest): FinishRequestEvent
|
||||
{
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$request = new Request();
|
||||
|
||||
return new FinishRequestEvent($kernel, $request, $mainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
|
||||
}
|
||||
}
|
||||
@@ -9,20 +9,214 @@
|
||||
|
||||
namespace App\Tests\EventSubscriber;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\EventSubscriber\WizardSubscriber;
|
||||
use App\Tests\Mocks\SystemConfigurationFactory;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
#[CoversClass(WizardSubscriber::class)]
|
||||
class WizardSubscriberTest extends TestCase
|
||||
{
|
||||
public function testGetSubscribedEvents(): void
|
||||
{
|
||||
$events = WizardSubscriber::getSubscribedEvents();
|
||||
self::assertArrayHasKey(KernelEvents::REQUEST, $events);
|
||||
$methodName = $events[KernelEvents::REQUEST][0];
|
||||
self::assertIsString($methodName);
|
||||
self::assertTrue(method_exists(WizardSubscriber::class, $methodName));
|
||||
self::assertEquals([KernelEvents::REQUEST => ['onKernelRequest', -30]], WizardSubscriber::getSubscribedEvents());
|
||||
}
|
||||
|
||||
public function testOnKernelRequestIgnoresSubRequest(): void
|
||||
{
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->never())->method('getToken');
|
||||
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
|
||||
$event = $this->createRequestEvent('/dashboard', false);
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
public function testOnKernelRequestIgnoresMissingToken(): void
|
||||
{
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$security->expects($this->never())->method('isGranted');
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn(null);
|
||||
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<array{string}>
|
||||
*/
|
||||
public static function provideExcludedUris(): iterable
|
||||
{
|
||||
yield ['/api/timesheets'];
|
||||
yield ['/register/new'];
|
||||
yield ['/wizard/intro'];
|
||||
}
|
||||
|
||||
#[DataProvider('provideExcludedUris')]
|
||||
public function testOnKernelRequestIgnoresExcludedUris(string $uri): void
|
||||
{
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->expects($this->never())->method('getUser');
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$security->expects($this->never())->method('isGranted');
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
|
||||
$event = $this->createRequestEvent($uri);
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
public function testOnKernelRequestIgnoresNonUserToken(): void
|
||||
{
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->expects($this->once())->method('getUser')->willReturn($this->createMock(UserInterface::class));
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$security->expects($this->never())->method('isGranted');
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
|
||||
'user' => [
|
||||
'wizard' => true,
|
||||
]
|
||||
]));
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
public function testOnKernelRequestIgnoresUserWithoutFullAuthentication(): void
|
||||
{
|
||||
$user = new User();
|
||||
$token = $this->createUserToken($user);
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(false);
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
|
||||
'user' => [
|
||||
'wizard' => true,
|
||||
]
|
||||
]));
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
public function testOnKernelRequestIgnoresWizardForRegularUserIfDisabled(): void
|
||||
{
|
||||
$user = new User();
|
||||
$token = $this->createUserToken($user);
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator->expects($this->never())->method('generate');
|
||||
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(true);
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
|
||||
'user' => [
|
||||
'wizard' => false,
|
||||
]
|
||||
]));
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
public function testOnKernelRequestRedirectsToFirstUnseenWizard(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setWizardAsSeen('intro');
|
||||
$token = $this->createUserToken($user);
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator
|
||||
->expects($this->once())
|
||||
->method('generate')
|
||||
->with('wizard', ['wizard' => 'profile'])
|
||||
->willReturn('/wizard/profile');
|
||||
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(true);
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
|
||||
'user' => [
|
||||
'wizard' => true,
|
||||
]
|
||||
]));
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
self::assertSame('/wizard/profile', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
private function createUserToken(User $user): TokenInterface
|
||||
{
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->expects($this->once())->method('getUser')->willReturn($user);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function createRequestEvent(string $uri, bool $mainRequest = true): RequestEvent
|
||||
{
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$request = Request::create($uri);
|
||||
|
||||
return new RequestEvent($kernel, $request, $mainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
77
tests/Pdf/SafeRemoteContentClientTest.php
Normal file
77
tests/Pdf/SafeRemoteContentClientTest.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Pdf;
|
||||
|
||||
use App\Pdf\SafeRemoteContentClient;
|
||||
use Mpdf\PsrHttpMessageShim\Request;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpClient\Exception\TransportException;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
#[CoversClass(SafeRemoteContentClient::class)]
|
||||
class SafeRemoteContentClientTest extends TestCase
|
||||
{
|
||||
public function testSuccessfulResponseIsForwarded(): void
|
||||
{
|
||||
$client = new MockHttpClient(
|
||||
new MockResponse('image-bytes', ['http_code' => 200])
|
||||
);
|
||||
|
||||
$sut = new SafeRemoteContentClient($client);
|
||||
$response = $sut->sendRequest(new Request('GET', 'https://example.com/logo.png'));
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame('image-bytes', $response->getBody()->getContents());
|
||||
}
|
||||
|
||||
public function testNon2xxResponseIsForwardedWithoutThrowing(): void
|
||||
{
|
||||
$client = new MockHttpClient(
|
||||
new MockResponse('not found', ['http_code' => 404])
|
||||
);
|
||||
|
||||
$sut = new SafeRemoteContentClient($client);
|
||||
$response = $sut->sendRequest(new Request('GET', 'https://example.com/missing.png'));
|
||||
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testTransportExceptionResultsInNon2xxResponse(): void
|
||||
{
|
||||
// Simulates NoPrivateNetworkHttpClient blocking the request, a DNS
|
||||
// failure, or a connection timeout — none of which must crash the
|
||||
// PDF rendering pipeline.
|
||||
$client = new MockHttpClient(static function (): MockResponse {
|
||||
throw new TransportException('IP blocked');
|
||||
});
|
||||
|
||||
$sut = new SafeRemoteContentClient($client);
|
||||
$response = $sut->sendRequest(new Request('GET', 'http://127.0.0.1/internal'));
|
||||
|
||||
self::assertSame(502, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testRequestIsBlockedWhenWrappedWithNoPrivateNetworkHttpClient(): void
|
||||
{
|
||||
// Wraps a mock client that would otherwise succeed. The decorator
|
||||
// must reject the localhost URL before any request is dispatched.
|
||||
$inner = new MockHttpClient(new MockResponse('should-not-be-reached'));
|
||||
$safe = new NoPrivateNetworkHttpClient($inner);
|
||||
|
||||
$sut = new SafeRemoteContentClient($safe);
|
||||
$response = $sut->sendRequest(new Request('GET', 'http://127.0.0.1/internal'));
|
||||
|
||||
self::assertSame(502, $response->getStatusCode());
|
||||
self::assertSame('', $response->getBody()->getContents());
|
||||
}
|
||||
}
|
||||
81
tests/Security/LoginLinkTest.php
Normal file
81
tests/Security/LoginLinkTest.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Security;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Tests\KernelTestTrait;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Security\Http\LoginLink\Exception\InvalidLoginLinkException;
|
||||
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
|
||||
|
||||
/**
|
||||
* Regression test for GHSA-m492-gv72-xvxj: a login link (used for password
|
||||
* reset and admin on-demand login) must stop working once the user's password
|
||||
* has been changed.
|
||||
*/
|
||||
#[Group('integration')]
|
||||
class LoginLinkTest extends KernelTestCase
|
||||
{
|
||||
use KernelTestTrait;
|
||||
|
||||
private Request $request;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
self::bootKernel();
|
||||
|
||||
// the login link handler is firewall-aware and needs an active request
|
||||
// on the stack to resolve the firewall it belongs to
|
||||
$this->request = Request::create('http://localhost/');
|
||||
$stack = self::getContainer()->get(RequestStack::class);
|
||||
self::assertInstanceOf(RequestStack::class, $stack);
|
||||
$stack->push($this->request);
|
||||
}
|
||||
|
||||
private function getLoginLinkHandler(): LoginLinkHandlerInterface
|
||||
{
|
||||
/** @var LoginLinkHandlerInterface $handler */
|
||||
$handler = self::getContainer()->get(LoginLinkHandlerInterface::class);
|
||||
|
||||
return $handler;
|
||||
}
|
||||
|
||||
public function testLoginLinkIsValidBeforePasswordChange(): void
|
||||
{
|
||||
$user = $this->getUserByRole(User::ROLE_USER);
|
||||
$handler = $this->getLoginLinkHandler();
|
||||
|
||||
$link = $handler->createLoginLink($user, $this->request);
|
||||
|
||||
$consumed = $handler->consumeLoginLink(Request::create($link->getUrl()));
|
||||
|
||||
self::assertSame($user->getUserIdentifier(), $consumed->getUserIdentifier());
|
||||
}
|
||||
|
||||
public function testLoginLinkIsRejectedAfterPasswordChange(): void
|
||||
{
|
||||
$user = $this->getUserByRole(User::ROLE_USER);
|
||||
$handler = $this->getLoginLinkHandler();
|
||||
|
||||
$link = $handler->createLoginLink($user, $this->request);
|
||||
|
||||
// simulate the user completing the password reset wizard: the password
|
||||
// hash changes, which must invalidate the signature of the old link
|
||||
$user->setPassword('$2y$13$changedchangedchangedchangedchangedchangedchangedchangedchg');
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->expectException(InvalidLoginLinkException::class);
|
||||
$handler->consumeLoginLink(Request::create($link->getUrl()));
|
||||
}
|
||||
}
|
||||
@@ -859,6 +859,90 @@ class RolePermissionManagerTest extends TestCase
|
||||
self::assertFalse($sut->checkUserAccess($subject, $requester));
|
||||
}
|
||||
|
||||
public function testCheckUserAccessAllowsDisabledSubjectWhenOnlyEnabledIsFalse(): void
|
||||
{
|
||||
$sut = $this->createSut();
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(false);
|
||||
|
||||
$requester = self::userWithId(2);
|
||||
$team = new Team('Support');
|
||||
$team->addUser($subject);
|
||||
$team->addTeamlead($requester);
|
||||
|
||||
// with $onlyEnabled = true (default) the disabled flag denies access
|
||||
self::assertFalse($sut->checkUserAccess($subject, $requester));
|
||||
// with $onlyEnabled = false the disabled flag is ignored and the teamlead path grants access
|
||||
self::assertTrue($sut->checkUserAccess($subject, $requester, false));
|
||||
}
|
||||
|
||||
public function testCheckUserAccessOnlyEnabledFalseStillRequiresAccessPath(): void
|
||||
{
|
||||
// disabling the "enabled" check must not bypass the rest of the access logic:
|
||||
// a requester without team relation must still be denied.
|
||||
$sut = $this->createSut();
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(false);
|
||||
(new Team('Subject team'))->addUser($subject);
|
||||
|
||||
$requester = self::userWithId(2);
|
||||
$requester->setRoles([User::ROLE_TEAMLEAD]);
|
||||
|
||||
self::assertFalse($sut->checkUserAccess($subject, $requester, false));
|
||||
}
|
||||
|
||||
public function testCheckUserAccessOnlyEnabledFalseStillBlocksSystemAccountSubject(): void
|
||||
{
|
||||
// the system-account guard sits below the "enabled" check, so it must still
|
||||
// apply when $onlyEnabled = false.
|
||||
$sut = $this->createSut();
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(false);
|
||||
$subject->setSystemAccount(true);
|
||||
|
||||
$requester = self::userWithId(2);
|
||||
$team = new Team('Support');
|
||||
$team->addUser($subject);
|
||||
$team->addTeamlead($requester);
|
||||
|
||||
self::assertFalse($sut->checkUserAccess($subject, $requester, false));
|
||||
}
|
||||
|
||||
public function testCheckUserAccessOnlyEnabledFalseGrantsAdminFallbackForDisabledTeamlessUser(): void
|
||||
{
|
||||
$sut = $this->createSut();
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(false);
|
||||
|
||||
$requester = self::userWithId(2);
|
||||
$requester->setRoles([User::ROLE_ADMIN]);
|
||||
|
||||
self::assertTrue($subject->isRegularUserOnly());
|
||||
self::assertSame([], $subject->getTeams());
|
||||
self::assertFalse($sut->checkUserAccess($subject, $requester));
|
||||
self::assertTrue($sut->checkUserAccess($subject, $requester, false));
|
||||
}
|
||||
|
||||
public function testCheckUserAccessOnlyEnabledFalseStillGrantsSuperAdmin(): void
|
||||
{
|
||||
// super-admin / canSeeAllData is decided before the "enabled" check,
|
||||
// so the result must not change with the flag.
|
||||
$sut = $this->createSut();
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(false);
|
||||
|
||||
$requester = self::userWithId(2);
|
||||
$requester->setSuperAdmin(true);
|
||||
|
||||
self::assertTrue($sut->checkUserAccess($subject, $requester));
|
||||
self::assertTrue($sut->checkUserAccess($subject, $requester, false));
|
||||
}
|
||||
|
||||
public function testCheckUserAccessDeniesSystemAccountForNonSystemRequester(): void
|
||||
{
|
||||
$sut = $this->createSut();
|
||||
|
||||
@@ -28,7 +28,7 @@ class ParsedownExtensionTest extends TestCase
|
||||
| Another entry | € 111 |
|
||||
| | |
|
||||
| Total | A lot |');
|
||||
self::assertStringStartsWith('<table class="table">', $html);
|
||||
self::assertStringStartsWith('<table class="table table-striped table-vcenter">', $html);
|
||||
}
|
||||
|
||||
public function testHeaderIsNotConverted(): void
|
||||
@@ -39,4 +39,23 @@ class ParsedownExtensionTest extends TestCase
|
||||
');
|
||||
self::assertEquals('<p># Foo</p>', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown image syntax must never emit an `<img>` tag, otherwise mPDF
|
||||
* (server-side) or the browser (UI) would auto-fetch the remote URL.
|
||||
*
|
||||
* @see https://github.com/kimai/kimai/security/advisories/GHSA-pj8j-p4g4-4vw8
|
||||
*/
|
||||
public function testMarkdownImageIsRewrittenAsLink(): void
|
||||
{
|
||||
$sut = new ParsedownExtension();
|
||||
$sut->setSafeMode(true);
|
||||
$sut->setMarkupEscaped(true);
|
||||
|
||||
$html = $sut->text('');
|
||||
|
||||
self::assertStringNotContainsString('<img', $html);
|
||||
self::assertStringContainsString('href="http://attacker.example/p.png"', $html);
|
||||
self::assertStringContainsString('>probe</a>', $html);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,4 +50,74 @@ class ParsedownTest extends TestCase
|
||||
<h1 id="foo-1">Foo</h1>
|
||||
<h1 id="foo-2">Foo</h1>', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown image syntax must never emit an `<img>` tag, otherwise mPDF
|
||||
* (server-side) or the browser (UI) would auto-fetch the remote URL.
|
||||
*
|
||||
* @see https://github.com/kimai/kimai/security/advisories/GHSA-pj8j-p4g4-4vw8
|
||||
*/
|
||||
public function testMarkdownImageIsRewrittenAsLink(): void
|
||||
{
|
||||
$sut = new Parsedown();
|
||||
$sut->setSafeMode(true);
|
||||
$sut->setMarkupEscaped(true);
|
||||
|
||||
$html = $sut->text('');
|
||||
|
||||
self::assertStringNotContainsString('<img', $html);
|
||||
self::assertStringContainsString('href="http://attacker.example/p.png"', $html);
|
||||
self::assertStringContainsString('>probe</a>', $html);
|
||||
self::assertStringContainsString('target="_blank"', $html);
|
||||
self::assertStringContainsString('rel="noopener noreferrer"', $html);
|
||||
}
|
||||
|
||||
public function testMarkdownImageWithoutAltUsesUrlAsLabel(): void
|
||||
{
|
||||
$sut = new Parsedown();
|
||||
$sut->setSafeMode(true);
|
||||
$sut->setMarkupEscaped(true);
|
||||
|
||||
$html = $sut->text('');
|
||||
|
||||
self::assertStringNotContainsString('<img', $html);
|
||||
self::assertStringContainsString('>http://attacker.example/p.png</a>', $html);
|
||||
}
|
||||
|
||||
public function testReferenceStyleImageIsRewrittenAsLink(): void
|
||||
{
|
||||
$sut = new Parsedown();
|
||||
$sut->setSafeMode(true);
|
||||
$sut->setMarkupEscaped(true);
|
||||
|
||||
$html = $sut->text("![probe][ref]\n\n[ref]: http://attacker.example/p.png");
|
||||
|
||||
self::assertStringNotContainsString('<img', $html);
|
||||
self::assertStringContainsString('href="http://attacker.example/p.png"', $html);
|
||||
self::assertStringContainsString('>probe</a>', $html);
|
||||
}
|
||||
|
||||
public function testRawHtmlImageIsEscaped(): void
|
||||
{
|
||||
$sut = new Parsedown();
|
||||
$sut->setSafeMode(true);
|
||||
$sut->setMarkupEscaped(true);
|
||||
|
||||
$html = $sut->text('<img src="http://attacker.example/p.png">');
|
||||
|
||||
self::assertStringNotContainsString('<img', $html);
|
||||
self::assertStringContainsString('<img', $html);
|
||||
}
|
||||
|
||||
public function testJavascriptUrlInImageIsNeutralised(): void
|
||||
{
|
||||
$sut = new Parsedown();
|
||||
$sut->setSafeMode(true);
|
||||
$sut->setMarkupEscaped(true);
|
||||
|
||||
$html = $sut->text(')');
|
||||
|
||||
self::assertStringNotContainsString('<img', $html);
|
||||
self::assertStringNotContainsString('href="javascript:', $html);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,19 +192,6 @@ class TimesheetVoterTest extends AbstractVoterTestCase
|
||||
$this->assertVote($other, $timesheet, 'is_owner', VoterInterface::ACCESS_DENIED);
|
||||
}
|
||||
|
||||
public function testIsOwnerUsesObjectIdentityNotId(): void
|
||||
{
|
||||
// The is_owner branch compares with strict identity ($user === $subject->getUser()),
|
||||
// not by id like the permission-based branches. Two distinct User instances that
|
||||
// share the same id are therefore NOT considered the same owner.
|
||||
$tokenUser = self::getUser(1, User::ROLE_USER);
|
||||
$timesheetUser = self::getUser(1, User::ROLE_USER);
|
||||
|
||||
$timesheet = self::getTimesheet($timesheetUser);
|
||||
|
||||
$this->assertVote($tokenUser, $timesheet, 'is_owner', VoterInterface::ACCESS_DENIED);
|
||||
}
|
||||
|
||||
public function testIsOwnerDeniedWhenTimesheetHasNoUser(): void
|
||||
{
|
||||
$user = self::getUser(1, User::ROLE_USER);
|
||||
@@ -653,6 +640,99 @@ class TimesheetVoterTest extends AbstractVoterTestCase
|
||||
$this->assertVote($requester, $timesheet, 'view', VoterInterface::ACCESS_GRANTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reproduces GHSA-c6w6-57jj-62vh.
|
||||
*
|
||||
* After a user loses team access to a project, "restart" (start) and
|
||||
* "duplicate" must NOT be allowed on one of their own historical timesheets:
|
||||
* both operations derive a brand-new record from the old entry and would
|
||||
* therefore create a new write under the now-unauthorized project/activity.
|
||||
*
|
||||
* The "_own_timesheet" branch in the voter currently short-circuits before
|
||||
* checkTeamAccess*() runs, so this test FAILS on vulnerable code (the voter
|
||||
* returns ACCESS_GRANTED) and documents the expected secure behaviour.
|
||||
*/
|
||||
public function testStartAndDuplicateDeniedAfterProjectAccessRevoked(): void
|
||||
{
|
||||
$owner = self::getUser(1, User::ROLE_USER);
|
||||
|
||||
// The project is now restricted to a team the owner is NOT a member of.
|
||||
// This is the post-revocation state from the advisory's PoC.
|
||||
$restrictedTeam = new Team('restricted after revocation');
|
||||
|
||||
$customer = new Customer('Acme');
|
||||
$project = new Project();
|
||||
$project->setCustomer($customer);
|
||||
$project->addTeam($restrictedTeam);
|
||||
|
||||
$activity = new Activity();
|
||||
$activity->setProject($project);
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setUser($owner);
|
||||
$timesheet->setProject($project);
|
||||
$timesheet->setActivity($activity);
|
||||
|
||||
$this->assertVote($owner, $timesheet, 'start', VoterInterface::ACCESS_DENIED);
|
||||
$this->assertVote($owner, $timesheet, 'duplicate', VoterInterface::ACCESS_DENIED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reproduces GHSA-c6w6-57jj-62vh for activity-level restriction.
|
||||
*
|
||||
* Even if the project is unrestricted, a restricted activity (a team the
|
||||
* owner is no longer in) must block restart/duplicate.
|
||||
*/
|
||||
public function testStartAndDuplicateDeniedAfterActivityAccessRevoked(): void
|
||||
{
|
||||
$owner = self::getUser(1, User::ROLE_USER);
|
||||
|
||||
$customer = new Customer('Acme');
|
||||
$project = new Project();
|
||||
$project->setCustomer($customer);
|
||||
|
||||
$activity = new Activity();
|
||||
$activity->setProject($project);
|
||||
$activity->addTeam(new Team('restricted after revocation'));
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setUser($owner);
|
||||
$timesheet->setProject($project);
|
||||
$timesheet->setActivity($activity);
|
||||
|
||||
$this->assertVote($owner, $timesheet, 'start', VoterInterface::ACCESS_DENIED);
|
||||
$this->assertVote($owner, $timesheet, 'duplicate', VoterInterface::ACCESS_DENIED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Positive control for GHSA-c6w6-57jj-62vh: when the owner still has team
|
||||
* access to the project and activity, restart/duplicate stay allowed.
|
||||
* Guards the fix against over-restriction.
|
||||
*/
|
||||
public function testStartAndDuplicateGrantedWhenOwnerStillHasProjectAccess(): void
|
||||
{
|
||||
$owner = self::getUser(1, User::ROLE_USER);
|
||||
|
||||
$team = new Team('still a member');
|
||||
$team->addUser($owner);
|
||||
|
||||
$customer = new Customer('Acme');
|
||||
$project = new Project();
|
||||
$project->setCustomer($customer);
|
||||
$project->addTeam($team);
|
||||
|
||||
$activity = new Activity();
|
||||
$activity->setProject($project);
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setUser($owner);
|
||||
$timesheet->setProject($project);
|
||||
$timesheet->setActivity($activity);
|
||||
|
||||
$this->assertVote($owner, $timesheet, 'start', VoterInterface::ACCESS_GRANTED);
|
||||
$this->assertVote($owner, $timesheet, 'duplicate', VoterInterface::ACCESS_GRANTED);
|
||||
}
|
||||
|
||||
private static function getTimesheetFor(User $owner, ?Team $customerTeam = null, ?Team $projectTeam = null, ?Team $activityTeam = null): Timesheet
|
||||
{
|
||||
$customer = new Customer('Acme');
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Tests\Voter;
|
||||
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\User;
|
||||
use App\Voter\UserVoter;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
@@ -119,4 +120,137 @@ class UserVoterTest extends AbstractVoterTestCase
|
||||
self::assertEquals(VoterInterface::ACCESS_GRANTED, $sut->vote($token, $user, ['view_team_member']));
|
||||
self::assertEquals(VoterInterface::ACCESS_DENIED, $sut->vote($token, $userMock, ['view_team_member']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Even with the "<attribute>_other_profile" role permission, access to another user's
|
||||
* profile must additionally pass the team-membership check in
|
||||
* RolePermissionManager::checkUserAccess().
|
||||
*/
|
||||
public function testOtherProfileRequiresTeamRelation(): void
|
||||
{
|
||||
$teamlead = self::getUser(10, User::ROLE_TEAMLEAD);
|
||||
$teamlead->setEnabled(true);
|
||||
$foreignUser = self::getUser(11, User::ROLE_USER);
|
||||
$foreignUser->setEnabled(true);
|
||||
|
||||
// give the foreign user a team that the teamlead is NOT part of,
|
||||
// so the special "subject has no teams" fallback does not kick in
|
||||
$team = new Team('foreign team');
|
||||
$foreignUser->addTeam($team);
|
||||
|
||||
$permissions = [
|
||||
'ROLE_TEAMLEAD' => ['view_other_profile', 'edit_other_profile'],
|
||||
];
|
||||
$rpm = $this->getRolePermissionManager($permissions, true);
|
||||
$voter = new UserVoter($rpm);
|
||||
|
||||
$token = new UsernamePasswordToken($teamlead, 'bar', $teamlead->getRoles());
|
||||
|
||||
// the role permission "view_other_profile" exists, but the team relation is missing
|
||||
self::assertEquals(VoterInterface::ACCESS_DENIED, $voter->vote($token, $foreignUser, ['view']));
|
||||
self::assertEquals(VoterInterface::ACCESS_DENIED, $voter->vote($token, $foreignUser, ['edit']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Same setup as above, but the current user IS teamlead of one of the subject's
|
||||
* teams — access should then be granted.
|
||||
*/
|
||||
public function testOtherProfileGrantedWhenUserIsTeamleadOfSubject(): void
|
||||
{
|
||||
$teamlead = self::getUser(20, User::ROLE_TEAMLEAD);
|
||||
$teamlead->setEnabled(true);
|
||||
$member = self::getUser(21, User::ROLE_USER);
|
||||
$member->setEnabled(true);
|
||||
|
||||
$team = new Team('shared team');
|
||||
$team->addUser($member);
|
||||
$team->addTeamlead($teamlead);
|
||||
|
||||
$permissions = [
|
||||
'ROLE_TEAMLEAD' => ['view_other_profile', 'edit_other_profile'],
|
||||
];
|
||||
$rpm = $this->getRolePermissionManager($permissions, true);
|
||||
$voter = new UserVoter($rpm);
|
||||
|
||||
$token = new UsernamePasswordToken($teamlead, 'bar', $teamlead->getRoles());
|
||||
|
||||
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $member, ['view']));
|
||||
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $member, ['edit']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that disabled profiles can still be edited (e.g. to reactivate them or check historic data).
|
||||
*/
|
||||
public function testOtherProfileAllowedForDisabledSubject(): void
|
||||
{
|
||||
$teamlead = self::getUser(30, User::ROLE_TEAMLEAD);
|
||||
$teamlead->setEnabled(true);
|
||||
$member = self::getUser(31, User::ROLE_USER);
|
||||
$member->setEnabled(false);
|
||||
|
||||
$team = new Team('shared team');
|
||||
$team->addUser($member);
|
||||
$team->addTeamlead($teamlead);
|
||||
|
||||
$permissions = [
|
||||
'ROLE_TEAMLEAD' => ['view_other_profile', 'edit_other_profile'],
|
||||
];
|
||||
$rpm = $this->getRolePermissionManager($permissions, true);
|
||||
$voter = new UserVoter($rpm);
|
||||
|
||||
$token = new UsernamePasswordToken($teamlead, 'bar', $teamlead->getRoles());
|
||||
|
||||
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $member, ['view']));
|
||||
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $member, ['edit']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Special case in checkUserAccess(): if the subject has no teams at all and the
|
||||
* current user is a teamlead/admin, access is granted (small-installation case).
|
||||
*/
|
||||
public function testOtherProfileGrantedForTeamlessSubjectWhenUserIsTeamlead(): void
|
||||
{
|
||||
$teamlead = self::getUser(40, User::ROLE_TEAMLEAD);
|
||||
$teamlead->setEnabled(true);
|
||||
$lonelyUser = self::getUser(41, User::ROLE_USER);
|
||||
$lonelyUser->setEnabled(true);
|
||||
|
||||
$permissions = [
|
||||
'ROLE_TEAMLEAD' => ['view_other_profile', 'edit_other_profile'],
|
||||
];
|
||||
$rpm = $this->getRolePermissionManager($permissions, true);
|
||||
$voter = new UserVoter($rpm);
|
||||
|
||||
$token = new UsernamePasswordToken($teamlead, 'bar', $teamlead->getRoles());
|
||||
|
||||
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $lonelyUser, ['view']));
|
||||
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $lonelyUser, ['edit']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Without the role permission "<attribute>_other_profile" the voter must deny,
|
||||
* regardless of any team relation between user and subject.
|
||||
*/
|
||||
public function testOtherProfileDeniedWithoutRolePermissionEvenWithTeamRelation(): void
|
||||
{
|
||||
$teamlead = self::getUser(50, User::ROLE_TEAMLEAD);
|
||||
$teamlead->setEnabled(true);
|
||||
$member = self::getUser(51, User::ROLE_USER);
|
||||
$member->setEnabled(true);
|
||||
|
||||
$team = new Team('shared team');
|
||||
$team->addUser($member);
|
||||
$team->addTeamlead($teamlead);
|
||||
|
||||
// no "view_other_profile" permission for ROLE_TEAMLEAD
|
||||
$permissions = [
|
||||
'ROLE_TEAMLEAD' => ['view_own_profile'],
|
||||
];
|
||||
$rpm = $this->getRolePermissionManager($permissions, true);
|
||||
$voter = new UserVoter($rpm);
|
||||
|
||||
$token = new UsernamePasswordToken($teamlead, 'bar', $teamlead->getRoles());
|
||||
|
||||
self::assertEquals(VoterInterface::ACCESS_DENIED, $voter->vote($token, $member, ['view']));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ parameters:
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#5 \\$content of method App\\\\Tests\\\\API\\\\APIControllerBaseTestCase\\:\\:request\\(\\) expects string\\|null, string\\|false given\\.$#"
|
||||
count: 23
|
||||
count: 25
|
||||
path: API/TeamControllerTest.php
|
||||
|
||||
-
|
||||
|
||||
Reference in New Issue
Block a user