Compare commits

..

6 Commits
2.52.0 ... main

Author SHA1 Message Date
Weblate (bot)
d456cd3ce2 Translated using Weblate (#5892)
Co-authored-by: Lasse Edsvik <lasse@lasseedsvik.se>
Co-authored-by: Milo Ivir <mail@milotype.de>
2026-04-13 21:23:49 +02:00
Kevin Papst
bad92d7215 Release 2.54 (#5896) 2026-04-13 21:22:06 +02:00
Kevin Papst
16703081cd Exporter/Invoice formula cleanup (#5899) 2026-04-12 09:27:05 +02:00
Kevin Papst
cbdf91f316 Team API docs (#5897)
* let view_team permission be handled by global ACLs
* code style and API docs
* improve permission check
2026-04-11 17:22:59 +02:00
Kevin Papst
999d820d4c Release 2.53 (#5878) 2026-04-10 18:09:27 +02:00
Weblate (bot)
fe4185ae45 Translated using Weblate (#5879)
Co-authored-by: C. H. <them4z@gmail.com>
Co-authored-by: Kevin Papst <kevin@kevinpapst.de>
Co-authored-by: Patryk <patryk230206@gmail.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: remo <remohexa@gmail.com>
Co-authored-by: தமிழ்நேரம் <tamilneram247@gmail.com>
2026-04-08 23:15:13 +02:00
125 changed files with 1597 additions and 628 deletions

View File

@@ -16,13 +16,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{secrets.DOCKERHUB_USERNAME}}
password: ${{secrets.DOCKERHUB_PASSWORD}}
@@ -48,7 +48,7 @@ jobs:
echo "kimai_version=$version" >> $GITHUB_ENV
- name: FPM image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -63,7 +63,7 @@ jobs:
push: true
- name: Apache image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -79,7 +79,7 @@ jobs:
push: true
- name: Development image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile

View File

@@ -16,7 +16,7 @@ jobs:
action:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
- uses: dessant/lock-threads@v6
with:
process-only: 'issues, prs'
github-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -20,11 +20,11 @@ jobs:
update_release_draft:
permissions:
contents: write # for release-drafter/release-drafter to create a github release
pull-requests: write # for release-drafter/release-drafter to add label to PR
contents: write # for release-drafter/release-drafter to create a github release
pull-requests: read
needs: correct_repository
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v6
- uses: release-drafter/release-drafter@v7
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -25,7 +25,7 @@ jobs:
steps:
- name: Clone Kimai
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
persist-credentials: false
@@ -47,7 +47,7 @@ jobs:
run: echo "composer_cache_directory=$(composer config cache-dir)" >> $GITHUB_ENV
- name: Cache Composer dependencies
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: "${{ env.composer_cache_directory }}"
key: ${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
@@ -104,7 +104,7 @@ jobs:
- name: Upload code coverage
if: matrix.php == '8.5'
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml

View File

@@ -34,7 +34,7 @@ jobs:
fi
- name: Emit repository_dispatch
uses: peter-evans/repository-dispatch@v3
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ secrets.WEBSITE_ACCESS_TOKEN }}
repository: kimai/www.kimai.org

View File

@@ -96,6 +96,7 @@ export default class KimaiPlugin {
}
/**
* @deprecated use the plugin directly
* @param {string} title
* @returns {string}
*/

View File

@@ -83,7 +83,7 @@ export default class KimaiTeamForm extends KimaiFormPlugin {
prototype.dataset['widgetCounter'] = (++counter).toString();
const temp = document.createElement('div');
temp.innerHTML = newWidget;
temp.innerHTML = ESCAPER.sanitize(newWidget);
temp.querySelector('input[type=hidden]').value = option.value;
const newNode = temp.firstElementChild;

View File

@@ -74,9 +74,6 @@ export default class KimaiAPILink extends KimaiPlugin {
const successHandle = () => {
EVENTS.trigger(eventName);
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
if (attributes['msgSuccess'] !== undefined) {
ALERT.success(attributes['msgSuccess']);
}
};
const errorHandle = (error) => {
let message = 'action.update.error';

View File

@@ -10,6 +10,7 @@
*/
import KimaiPlugin from "../KimaiPlugin";
import DOMPurify from "dompurify";
export default class KimaiEscape extends KimaiPlugin {
@@ -26,14 +27,23 @@ export default class KimaiEscape extends KimaiPlugin {
return '';
}
const tagsToReplace = {
const charToReplace = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
};
return title.replace(/[&<>]/g, function(tag) {
return tagsToReplace[tag] || tag;
return title.replace(/[&<>"]/g, function(tag) {
return charToReplace[tag] || tag;
});
}
/**
* @param {string} html
* @returns {string}
*/
sanitize(html) {
return DOMPurify.sanitize(html);
}
}

View File

@@ -663,7 +663,7 @@ export default class KimaiCalendar {
}
}
return `
return escaper.sanitize(`
<div class="calendar-entry">
<ul>
<li>` + this.options['translations']['customer'] + `: ` + escaper.escapeForHtml(eventObj.customer) + `</li>
@@ -672,7 +672,7 @@ export default class KimaiCalendar {
</ul>` +
(eventObj.description !== null || eventObj.tags.length > 0 ? '<hr>' : '') +
(eventObj.description ? '<div>' + escaper.escapeForHtml(eventObj.description) + '</div>' : '') + tags + `
</div>`;
</div>`);
}
/**

View File

@@ -22,7 +22,7 @@
.time-off { color: var(--kimai-time-off); }
.other, .parental, .unpaid-vacation { color: var(--kimai-other); }
.bg-public-holiday{ background-color: var(--kimai-public-holiday-bg); --tblr-table-bg: var(--kimai-public-holiday-bg); i.fas{ color: var(--kimai-public-holiday); } };
.bg-public-holiday { background-color: var(--kimai-public-holiday-bg); --tblr-table-bg: var(--kimai-public-holiday-bg); i.fas{ color: var(--kimai-public-holiday); } };
.bg-holiday { background-color: var(--kimai-holiday-bg); --tblr-table-bg: var(--kimai-holiday-bg); i.fas{ color: var(--kimai-holiday); } };
.bg-sickness, .bg-sickness-child { background-color: var(--kimai-sickness-bg); --tblr-table-bg: var(--kimai-sickness-bg); i.fas{ color: var(--kimai-sickness); } };
.bg-time-off { background-color: var(--kimai-time-off-bg); --tblr-table-bg: var(--kimai-time-off-bg); i.fas{ color: var(--kimai-time-off); } };

397
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,10 @@
framework:
rate_limiter:
old_api_tokens:
policy: 'fixed_window'
limit: 5
interval: '1 minute'
lock_factory: null
session_prediction:
policy: 'fixed_window'
limit: 250

View File

@@ -42,6 +42,7 @@
"bootstrap": "^5.3",
"chart.js": "^4",
"core-js": "^3",
"dompurify": "^3",
"eslint": "^9",
"globals": "^15",
"gridstack": "^7",

View File

@@ -1665,11 +1665,6 @@ parameters:
count: 1
path: src/Form/Extension/SelectWithApiDataExtension.php
-
message: "#^Parameter \\#1 \\$name of method Symfony\\\\Component\\\\Routing\\\\Generator\\\\UrlGeneratorInterface\\:\\:generate\\(\\) expects string, mixed given\\.$#"
count: 1
path: src/Form/Extension/SelectWithApiDataExtension.php
-
message: "#^Property App\\\\Form\\\\Helper\\\\ActivityHelper\\:\\:\\$pattern \\(string\\|null\\) does not accept bool\\|float\\|int\\|string\\|null\\.$#"
count: 1
@@ -2787,7 +2782,7 @@ parameters:
-
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, mixed given\\.$#"
count: 5
count: 4
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
-

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -175,3 +175,5 @@
/*!
* [KIMAI] Wrapper class for loading Kimai app in browser script scope
*/
/*! @license DOMPurify 3.3.3 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.3.3/LICENSE */

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
"app": {
"js": [
"/build/runtime.684e9f6d.js",
"/build/app.41748d55.js"
"/build/app.f0f8091d.js"
],
"css": [
"/build/app.99ea4166.css"
@@ -54,7 +54,7 @@
"calendar": {
"js": [
"/build/runtime.684e9f6d.js",
"/build/calendar.7e3186b2.js"
"/build/calendar.13247e65.js"
],
"css": [
"/build/calendar.d757753e.css"
@@ -81,7 +81,7 @@
},
"integrity": {
"/build/runtime.684e9f6d.js": "sha384-suKiEX2de4fdNqQzdYbUd6osp4AepD9FiMXl+1QdvgMW9dcQqUWQNQasf3KWzwLr",
"/build/app.41748d55.js": "sha384-iVPHnOXYjVDuq8567h8I3U2gl5SCV3mNMFNQCJdgMiNuFMZ4KO7PXOLanjgcGzTt",
"/build/app.f0f8091d.js": "sha384-F6UUWeiIwbFffkZMRmbczLyw5tuOvtUNLuX1/iY6ZfncN1vYGSgalEumAp4HXFvX",
"/build/app.99ea4166.css": "sha384-OF0ozOygdShRhEacN7Tb8YtDSXABczVqjmXRYQjX1YGTmuRQMNCipFTqzHd9IyCH",
"/build/app-rtl.15853b82.js": "sha384-UnKKgLMu9FnRT+CFE0no/+UiUks012bYriQdUWa6f02mo6Lswl947mPybjvKL503",
"/build/app-rtl.16262d9a.css": "sha384-CjN7UFkBszmM9k6xfN8LWH79IOsgpVTwwHoffvAOc04j9+y904uDw/Y+LnSQmrDj",
@@ -92,7 +92,7 @@
"/build/invoice-pdf.26d98626.js": "sha384-gwNzQiU1y6qU/M9DPGiNW0MVZkLctEHk37sCES2X9ov+zugEaDABdkMjKBYOC9lz",
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
"/build/chart.56f16a68.js": "sha384-SWnYjAbZ8OWEvTP+IZfGuBMWJIcH9OZWLYQ4p38KMPmqfufsc2zhzqWkALO3mCBO",
"/build/calendar.7e3186b2.js": "sha384-umm2cw67dX5I6+PJ+PpNuHwWgKDJc+r2MmpN8az3dtAqUGa3N9ZGHD0SHc7xfFVB",
"/build/calendar.13247e65.js": "sha384-8b3wBuxn8m2FsxLMtbIpZTg1SGBX9b+0dYRTnF63mGQfcXhRGHKtUs5REwVyhYiP",
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
"/build/dashboard.9708ae5e.js": "sha384-QN7XIQuxFZu76sHVrgdpZL81+Q2VTwcgF3aI2CnpwYZiMoPbzrcTGfPL3RDy696t",
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",

View File

@@ -1,6 +1,6 @@
{
"build/app.css": "/build/app.99ea4166.css",
"build/app.js": "/build/app.41748d55.js",
"build/app.js": "/build/app.f0f8091d.js",
"build/app-rtl.css": "/build/app-rtl.16262d9a.css",
"build/app-rtl.js": "/build/app-rtl.15853b82.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
@@ -11,7 +11,7 @@
"build/invoice-pdf.js": "/build/invoice-pdf.26d98626.js",
"build/chart.js": "/build/chart.56f16a68.js",
"build/calendar.css": "/build/calendar.d757753e.css",
"build/calendar.js": "/build/calendar.7e3186b2.js",
"build/calendar.js": "/build/calendar.13247e65.js",
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
"build/dashboard.js": "/build/dashboard.9708ae5e.js",
"build/highlight.css": "/build/highlight.98bf3927.css",

View File

@@ -71,6 +71,7 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[OA\Get(x: ['internal' => true])]
#[Route(methods: ['GET'], path: '/timesheet/{id}/{view}/{locale}', name: 'get_timesheet_actions', requirements: ['id' => '\d+'])]
#[IsGranted('view', 'timesheet')]
public function getTimesheetActions(Timesheet $timesheet, string $view, string $locale): Response
{
$event = new PageActionsEvent($this->getUser(), ['timesheet' => $timesheet], 'timesheet', $view);
@@ -90,6 +91,7 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[OA\Get(x: ['internal' => true])]
#[Route(methods: ['GET'], path: '/activity/{id}/{view}/{locale}', name: 'get_activity_actions', requirements: ['id' => '\d+'])]
#[IsGranted('view', 'activity')]
public function getActivityActions(Activity $activity, string $view, string $locale): Response
{
$event = new PageActionsEvent($this->getUser(), ['activity' => $activity], 'activity', $view);
@@ -109,6 +111,7 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[OA\Get(x: ['internal' => true])]
#[Route(methods: ['GET'], path: '/project/{id}/{view}/{locale}', name: 'get_project_actions', requirements: ['id' => '\d+'])]
#[IsGranted('view', 'project')]
public function getProjectActions(Project $project, string $view, string $locale): Response
{
$event = new PageActionsEvent($this->getUser(), ['project' => $project], 'project', $view);
@@ -128,6 +131,7 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[OA\Get(x: ['internal' => true])]
#[Route(methods: ['GET'], path: '/customer/{id}/{view}/{locale}', name: 'get_customer_actions', requirements: ['id' => '\d+'])]
#[IsGranted('view', 'customer')]
public function getCustomerActions(Customer $customer, string $view, string $locale): Response
{
$event = new PageActionsEvent($this->getUser(), ['customer' => $customer], 'customer', $view);

View File

@@ -72,15 +72,14 @@ final class ActivityController extends BaseApiController
/** @var array<int> $projects */
$projects = $paramFetcher->get('projects');
$project = $paramFetcher->get('project');
if (\is_string($project) && $project !== '') {
$projects[] = $project;
$pr = $paramFetcher->get('project');
if (\is_string($pr) && $pr !== '') {
$projects[] = $pr;
}
foreach (array_unique($projects) as $projectId) {
$project = $projectRepository->find($projectId);
if ($project === null) {
throw $this->createNotFoundException('Unknown project: ' . $projectId);
foreach ($projectRepository->findByIds(array_unique($projects)) as $project) {
if (!$this->isGranted('access', $project)) {
throw $this->createAccessDeniedException('Cannot access Project: ' . $project->getId());
}
$query->addProject($project);
}

View File

@@ -36,8 +36,8 @@ final class ApiRequestMatcher implements RequestMatcherInterface
}
// let's use this firewall if the deprecated username & token combination is available
if ($request->headers->has(TokenAuthenticator::HEADER_USERNAME) &&
$request->headers->has(TokenAuthenticator::HEADER_TOKEN)) {
if ($request->headers->has(TokenAuthenticator::HEADER_USERNAME) && // @phpstan-ignore classConstant.deprecatedClass
$request->headers->has(TokenAuthenticator::HEADER_TOKEN)) { // @phpstan-ignore classConstant.deprecatedClass
return true;
}
// ------------------------------------------------------------------------------------

View File

@@ -14,6 +14,9 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
/**
* @deprecated since 2.54 - see https://www.kimai.org/en/blog/2026/removing-api-passwords
*/
final class ApiTokenMigratingListener implements EventSubscriberInterface
{
public function __construct(private PasswordHasherFactoryInterface $hasherFactory)

View File

@@ -13,6 +13,9 @@ use Symfony\Component\Security\Core\Exception\LogicException;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
/**
* @deprecated since 2.54 - see https://www.kimai.org/en/blog/2026/removing-api-passwords
*/
final class ApiTokenUpgradeBadge implements BadgeInterface
{
public function __construct(private ?string $plaintextApiToken, private readonly PasswordUpgraderInterface $passwordUpgrader)

View File

@@ -13,17 +13,24 @@ use App\Entity\User;
use App\Repository\ApiUserRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
/**
* @deprecated since 2.54 - see https://www.kimai.org/en/blog/2026/removing-api-passwords
*/
final class TokenAuthenticator extends AbstractAuthenticator
{
public const HEADER_USERNAME = 'X-AUTH-USER';
@@ -31,7 +38,9 @@ final class TokenAuthenticator extends AbstractAuthenticator
public function __construct(
private readonly ApiUserRepository $userProvider,
private readonly PasswordHasherFactoryInterface $passwordHasherFactory
private readonly PasswordHasherFactoryInterface $passwordHasherFactory,
private readonly RateLimiterFactory $oldApiTokensLimiter,
private readonly RequestStack $requestStack,
)
{
}
@@ -44,8 +53,6 @@ final class TokenAuthenticator extends AbstractAuthenticator
}
if ($request->headers->has(self::HEADER_USERNAME) && $request->headers->has(self::HEADER_TOKEN)) {
@trigger_error('You are using deprecated API access, please upgrade your APP to use API tokens instead.', E_USER_DEPRECATED);
return true;
}
}
@@ -77,10 +84,12 @@ final class TokenAuthenticator extends AbstractAuthenticator
$checkCredentials = function (?string $presentedPassword, User $user) {
if ('' === $presentedPassword) {
$this->rateLimitInvalidLogin();
throw new BadCredentialsException('The presented password cannot be empty.');
}
if (null === $user->getApiToken()) {
$this->rateLimitInvalidLogin();
throw new BadCredentialsException('The user has no activated API account.');
}
@@ -88,11 +97,17 @@ final class TokenAuthenticator extends AbstractAuthenticator
return true;
}
$this->rateLimitInvalidLogin();
throw new BadCredentialsException('The presented password is invalid.');
};
// users should really move away from this auth endpoint
// see https://www.kimai.org/en/blog/2026/removing-api-passwords
@trigger_error('Using deprecated API passwords, upgrade your APP to use API tokens instead.', E_USER_DEPRECATED);
usleep(mt_rand(200000, 500000));
$passport = new Passport(
new UserBadge($credentials['username'], [$this->userProvider, 'loadUserByIdentifier']),
new UserBadge($credentials['username'], [$this, 'loadUserByIdentifier']),
new CustomCredentials($checkCredentials, $credentials['password'])
);
@@ -101,6 +116,30 @@ final class TokenAuthenticator extends AbstractAuthenticator
return $passport;
}
public function loadUserByIdentifier(string $identifier): ?UserInterface
{
$user = $this->userProvider->loadUserByIdentifier($identifier);
if ($user === null) {
// we could use usleep(500000); to slow down potential attacks, but using a hashing makes timing attacks more difficult
$this->passwordHasherFactory->getPasswordHasher(User::class)->verify('$2y$13$vwn35gUbbivoS75wcByBzObCNjX4vwkBihbdXQuK23HzK1R6J5WKW', uniqid());
$this->rateLimitInvalidLogin();
}
return $user;
}
private function rateLimitInvalidLogin(): void
{
$limiter = $this->oldApiTokensLimiter->create($this->requestStack->getMainRequest()?->getClientIp());
$limit = $limiter->consume();
if (false === $limit->isAccepted()) {
throw new BadRequestHttpException('Too many API requests with invalid username. Possible attack?');
}
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;

View File

@@ -85,15 +85,14 @@ final class ProjectController extends BaseApiController
/** @var array<int> $customers */
$customers = $paramFetcher->get('customers');
$customer = $paramFetcher->get('customer');
if (\is_string($customer) && $customer !== '') {
$customers[] = $customer;
$cu = $paramFetcher->get('customer');
if (\is_string($cu) && $cu !== '') {
$customers[] = $cu;
}
foreach (array_unique($customers) as $customerId) {
$customer = $customerRepository->find($customerId);
if ($customer === null) {
throw $this->createNotFoundException('Unknown customer: ' . $customerId);
foreach ($customerRepository->findByIds(array_unique($customers)) as $customer) {
if (!$this->isGranted('access', $customer)) {
throw $this->createAccessDeniedException('Cannot access Customer: ' . $customer->getId());
}
$query->addCustomer($customer);
}

View File

@@ -71,7 +71,7 @@ final class TeamController extends BaseApiController
* Fetch team
*/
#[IsGranted('view_team')]
#[OA\Response(response: 200, description: 'Returns one team entity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))]
#[OA\Response(response: 200, description: 'Returns the team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_team', requirements: ['id' => '\d+'])]
public function getAction(Team $team): Response
{
@@ -84,8 +84,8 @@ final class TeamController extends BaseApiController
/**
* Delete team
*/
#[IsGranted('delete_team')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one team')])]
#[IsGranted('delete', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Empty')])]
#[OA\Parameter(name: 'id', in: 'path', description: 'Team ID to delete', required: true)]
#[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_team', requirements: ['id' => '\d+'])]
public function deleteAction(Team $team): Response
@@ -129,7 +129,7 @@ final class TeamController extends BaseApiController
/**
* Update team
*/
#[IsGranted('edit_team')]
#[IsGranted('edit', 'team')]
#[OA\Patch(description: 'Update an existing team, you can pass all or just a subset of all attributes (passing members will replace all existing ones)', responses: [new OA\Response(response: 200, description: 'Returns the updated team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TeamEditForm'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Team ID to update', required: true)]
@@ -169,7 +169,7 @@ final class TeamController extends BaseApiController
/**
* Add team member
*/
#[IsGranted('edit_team')]
#[IsGranted('edit', 'team')]
#[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)]
@@ -193,7 +193,7 @@ final class TeamController extends BaseApiController
/**
* Remove team member
*/
#[IsGranted('edit_team')]
#[IsGranted('edit', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a user from the team. The teamlead cannot be removed.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team from which the member will be removed', required: true)]
#[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to remove (User ID)', required: true)]
@@ -219,10 +219,12 @@ final class TeamController extends BaseApiController
}
/**
* Grant team access to customer
* Grant customer access
*
* The team is granted access to the customer.
*/
#[IsGranted('edit_team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new customer to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[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)]
#[Route(methods: ['POST'], path: '/{id}/customers/{customerId}', name: 'post_team_customer', requirements: ['id' => '\d+', 'customerId' => '\d+'])]
@@ -242,10 +244,12 @@ final class TeamController extends BaseApiController
}
/**
* Revoke customer access from team
* Revoke customer access
*
* This removes access to the customer from the team.
*/
#[IsGranted('edit_team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a customer from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Returns the team without the customer', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
#[OA\Parameter(name: 'customerId', in: 'path', description: 'The customer to remove (Customer ID)', required: true)]
#[Route(methods: ['DELETE'], path: '/{id}/customers/{customerId}', name: 'delete_team_customer', requirements: ['id' => '\d+', 'customerId' => '\d+'])]
@@ -265,10 +269,12 @@ final class TeamController extends BaseApiController
}
/**
* Grant team access to project
* Grant project access
*
* The team is granted access to the project.
*/
#[IsGranted('edit_team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new project to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[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)]
#[Route(methods: ['POST'], path: '/{id}/projects/{projectId}', name: 'post_team_project', requirements: ['id' => '\d+', 'projectId' => '\d+'])]
@@ -288,10 +294,12 @@ final class TeamController extends BaseApiController
}
/**
* Revoke project access from team
* Revoke project access
*
* This removes access to the project from the team.
*/
#[IsGranted('edit_team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a project from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Returns the team without the project', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
#[OA\Parameter(name: 'projectId', in: 'path', description: 'The project to remove (Project ID)', required: true)]
#[Route(methods: ['DELETE'], path: '/{id}/projects/{projectId}', name: 'delete_team_project', requirements: ['id' => '\d+', 'projectId' => '\d+'])]
@@ -311,10 +319,12 @@ final class TeamController extends BaseApiController
}
/**
* Grant team access to activity
* Grant activity access
*
* The team is granted access to the activity.
*/
#[IsGranted('edit_team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new activity to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[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)]
#[Route(methods: ['POST'], path: '/{id}/activities/{activityId}', name: 'post_team_activity', requirements: ['id' => '\d+', 'activityId' => '\d+'])]
@@ -334,10 +344,12 @@ final class TeamController extends BaseApiController
}
/**
* Revoke activity access from team
* Revoke activity access
*
* This removes access to the activity from the team.
*/
#[IsGranted('edit_team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a activity from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Returns the team without the activity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
#[OA\Parameter(name: 'activityId', in: 'path', description: 'The activity to remove (Activity ID)', required: true)]
#[Route(methods: ['DELETE'], path: '/{id}/activities/{activityId}', name: 'delete_team_activity', requirements: ['id' => '\d+', 'activityId' => '\d+'])]

View File

@@ -126,45 +126,42 @@ final class TimesheetController extends BaseApiController
/** @var array<int> $customers */
$customers = $paramFetcher->get('customers');
$customer = $paramFetcher->get('customer');
if (\is_string($customer) && $customer !== '') {
$customers[] = $customer;
$cu = $paramFetcher->get('customer');
if (\is_string($cu) && $cu !== '') {
$customers[] = $cu;
}
foreach (array_unique($customers) as $customerId) {
$customer = $customerRepository->find($customerId);
if ($customer === null) {
throw $this->createNotFoundException('Unknown customer: ' . $customerId);
foreach ($customerRepository->findByIds(array_unique($customers)) as $customer) {
if (!$this->isGranted('access', $customer)) {
throw $this->createAccessDeniedException('Cannot access Customer: ' . $customer->getId());
}
$query->addCustomer($customer);
}
/** @var array<int> $projects */
$projects = $paramFetcher->get('projects');
$project = $paramFetcher->get('project');
if (\is_string($project) && $project !== '') {
$projects[] = $project;
$pr = $paramFetcher->get('project');
if (\is_string($pr) && $pr !== '') {
$projects[] = $pr;
}
foreach (array_unique($projects) as $projectId) {
$project = $projectRepository->find($projectId);
if ($project === null) {
throw $this->createNotFoundException('Unknown project: ' . $project);
foreach ($projectRepository->findByIds(array_unique($projects)) as $project) {
if (!$this->isGranted('access', $project)) {
throw $this->createAccessDeniedException('Cannot access Project: ' . $project->getId());
}
$query->addProject($project);
}
/** @var array<int> $activities */
$activities = $paramFetcher->get('activities');
$activity = $paramFetcher->get('activity');
if (\is_string($activity) && $activity !== '') {
$activities[] = $activity;
$ac = $paramFetcher->get('activity');
if (\is_string($ac) && $ac !== '') {
$activities[] = $ac;
}
foreach (array_unique($activities) as $activityId) {
$activity = $activityRepository->find($activityId);
if ($activity === null) {
throw $this->createNotFoundException('Unknown activity: ' . $activity);
foreach ($activityRepository->findByIds(array_unique($activities)) as $activity) {
if (!$this->isGranted('access', $activity)) {
throw $this->createAccessDeniedException('Cannot access Activity: ' . $activity->getId());
}
$query->addActivity($activity);
}
@@ -234,7 +231,6 @@ final class TimesheetController extends BaseApiController
}
$data = $this->repository->getPagerfantaForQuery($query);
$view = new View($data, 200);
$full = $paramFetcher->get('full');
@@ -486,8 +482,7 @@ final class TimesheetController extends BaseApiController
$copy = $paramFetcher->get('copy');
if ($copy === 'all') {
$copyTimesheet->setHourlyRate($timesheet->getHourlyRate());
$copyTimesheet->setFixedRate($timesheet->getFixedRate());
// we do NOT copy rates, as those should always be calculated from the configured settings
$copyTimesheet->setDescription($timesheet->getDescription());
$copyTimesheet->setBillable($timesheet->isBillable());

View File

@@ -236,16 +236,18 @@ final class UserController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'User ID to set the custom-field value for', required: true)]
#[OA\RequestBody(required: true, content: new OA\JsonContent(type: 'array', items: new OA\Items(new Model(type: UserPreference::class))))]
#[Route(methods: ['PATCH'], path: '/{id}/preferences', requirements: ['id' => '\d+'])]
public function updateUserPreference(User $profile, Request $request, EventDispatcherInterface $dispatcher): Response
public function updateUserPreference(User $profile, Request $request, EventDispatcherInterface $dispatcher, UserService $userService): Response
{
$event = new PrepareUserEvent($profile, false);
$dispatcher->dispatch($event);
$dirty = false;
foreach ($request->request->all() as $preference) {
// why is this not handled by FosRestBundle ?
if (!\is_array($preference)) {
throw new BadRequestHttpException('Invalid request, array expected');
}
if (!\array_key_exists('name', $preference) || !\array_key_exists('value', $preference)) {
throw new BadRequestHttpException('Missing required parameter "name" or "value"');
}
@@ -253,14 +255,23 @@ final class UserController extends BaseApiController
$name = $preference['name'];
$value = $preference['value'];
// TODO allow to update preferences that are used internally but not registered via PrepareUserEvent
if (null === ($meta = $profile->getPreference($name))) {
throw $this->createNotFoundException(\sprintf('Unknown custom-field "%s" requested', $name));
}
if (!$meta->isEnabled()) {
throw $this->createAccessDeniedException('User tried to update preference: ' . $name);
}
$meta->setValue($value);
$dirty = true;
}
$this->repository->saveUser($profile);
if ($dirty) {
$userService->saveUser($profile);
}
$view = new View($profile, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);

View File

@@ -147,7 +147,6 @@ final class ResetTestCommand extends AbstractResetCommand
$userSuperAdmin->setPreferenceValue(UserPreference::HOURLY_RATE, 46);
$userSuperAdmin->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$userSuperAdmin->setTitle('Super Administrator');
$userSuperAdmin->setAvatar('/bundles/avanzuadmintheme/img/avatar.png');
$userSuperAdmin->setEnabled(true);
$userSuperAdmin->setRoles(['ROLE_SUPER_ADMIN']);
$userSuperAdmin->setUserIdentifier(UserFixtures::USERNAME_SUPER_ADMIN);

View File

@@ -11,7 +11,7 @@ namespace App\Configuration;
final class SamlConfiguration implements SamlConfigurationInterface
{
public function __construct(private SystemConfiguration $configuration)
public function __construct(private readonly SystemConfiguration $configuration)
{
}
@@ -54,4 +54,9 @@ final class SamlConfiguration implements SamlConfigurationInterface
{
return $this->configuration->getSamlConnection();
}
public function cleanupLongRelayState(): bool
{
return (bool) $this->configuration->find('saml.connection.cleanupLongRelayState');
}
}

View File

@@ -46,4 +46,7 @@ interface SamlConfigurationInterface
public function isRolesResetOnLogin(): bool;
public function getConnection(): array;
// TODO 3.0 activate me
//public function cleanupLongRelayState(): bool;
}

View File

@@ -91,7 +91,6 @@ final class SystemConfiguration
$array = &$replaced;
while (\count($keys) > 1) {
$search = array_shift($keys);
/* @phpstan-ignore-next-line */
if (!\array_key_exists($search, $array) || !\is_array($array[$search])) {
$array[$search] = [];
}

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.52.0';
public const VERSION = '2.54.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 25200;
public const VERSION_ID = 25400;
/**
* The software name
*/

View File

@@ -66,13 +66,20 @@ final class SamlController extends AbstractController
throw new ServiceUnavailableHttpException(message: 'Unknown firewall.');
}
// this can be an absolute URL including query parameters
$redirectTarget = $this->getTargetPath($session, $firewallName);
if ($redirectTarget === null || $redirectTarget === '') {
$redirectTarget = $this->generateUrl('homepage', [], UrlGeneratorInterface::ABSOLUTE_URL);
}
$url = $this->authFactory->create()->login($redirectTarget, [], false, false, true);
// the protocol defines max 80 byte for RelayState, even if most IdP support more - see #5752
if (method_exists($this->samlConfiguration, 'cleanupLongRelayState') && $this->samlConfiguration->cleanupLongRelayState()) {
if (\strlen($redirectTarget) > 80 && ($pos = stripos($redirectTarget, '?')) !== false) {
$redirectTarget = substr($redirectTarget, 0, $pos);
}
}
$url = $this->authFactory->create()->login($redirectTarget, [], false, false, true);
if ($url === null) {
throw new \RuntimeException('SAML login failed');
}

View File

@@ -26,19 +26,6 @@ use Symfony\Contracts\Cache\ItemInterface;
#[IsGranted('system_information')]
final class DoctorController extends AbstractController
{
/**
* Required PHP extensions for Kimai.
*/
public const REQUIRED_EXTENSIONS = [
'intl',
'json',
'mbstring',
'pdo',
'xml',
'xsl',
'zip',
];
/**
* Directories which need to be writable by the webserver.
*/
@@ -118,7 +105,8 @@ final class DoctorController extends AbstractController
*/
private function getOpcacheConfiguration(): array
{
$status = \function_exists('opcache_get_status') ? opcache_get_status() : false;
$known = \function_exists('opcache_get_status');
$status = $known ? opcache_get_status() : false;
$enabled = \is_array($status) && $status['opcache_enabled'];
@@ -127,6 +115,7 @@ final class DoctorController extends AbstractController
}
return [
'unknown' => !$known,
'enabled' => $enabled,
'status' => $status,
];
@@ -168,9 +157,27 @@ final class DoctorController extends AbstractController
*/
private function getLoadedExtensions(): array
{
$json = file_get_contents(__DIR__ . '/../../composer.json');
if ($json === false) {
return ['Failed loading composer.json' => false];
}
$composer = json_decode($json, true);
if (!\is_array($composer)) {
return ['Failed parsing composer.json' => false];
}
if (!\array_key_exists('require', $composer)) {
return ['Missing requirements in composer.json' => false];
}
$results = [];
foreach (self::REQUIRED_EXTENSIONS as $extName) {
foreach ($composer['require'] as $name => $version) {
if (!str_starts_with($name, 'ext-')) {
continue;
}
$extName = str_replace('ext-', '', $name);
$results[$extName] = false;
if (\extension_loaded($extName)) {
$results[$extName] = true;

View File

@@ -38,15 +38,9 @@ final class TeamController extends AbstractController
{
}
/**
* @param TeamRepository $repository
* @param Request $request
* @param int $page
* @return Response
*/
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_team', methods: ['GET'])]
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_team_paginated', methods: ['GET'])]
public function listTeams(TeamRepository $repository, Request $request, $page): Response
public function listTeams(int $page, TeamRepository $repository, Request $request): Response
{
$query = new TeamQuery();
$query->setPage($page);
@@ -81,10 +75,6 @@ final class TeamController extends AbstractController
]);
}
/**
* @param Request $request
* @return Response
*/
#[Route(path: '/create', name: 'admin_team_create', methods: ['GET', 'POST'])]
#[IsGranted('create_team')]
public function createTeam(Request $request): Response

View File

@@ -99,7 +99,7 @@ final class AppExtension extends Extension
foreach (range(0, $iterator->getDepth()) as $depth) {
$keys[] = $iterator->getSubIterator($depth)->key();
}
$newConfig[implode('.', $keys)] = $value;
$newConfig[implode('.', $keys)] = $value; // @phpstan-ignore argument.type
}
$container->setParameter('kimai.config', $newConfig);

View File

@@ -862,6 +862,9 @@ final class Configuration implements ConfigurationInterface
->scalarNode('baseurl')->end()
->booleanNode('strict')->end()
->booleanNode('debug')->end()
->booleanNode('cleanupLongRelayState')
->defaultFalse()
->end()
->arrayNode('idp')
->children()
->scalarNode('entityId')->end()

View File

@@ -60,6 +60,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
* Name of this activity
*/
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 150)]
#[Serializer\Expose]
@@ -117,6 +118,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[ORM\Column(name: 'invoice_text', type: Types::TEXT, nullable: true)]
private ?string $invoiceText = null;
#[ORM\Column(name: 'number', type: Types::STRING, length: 10, nullable: true)]
#[Constraints\NoSpecialCharacters]
#[Assert\Length(max: 10)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]

View File

@@ -45,6 +45,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Exporter\Expose(label: 'id', type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 150)]
#[Serializer\Expose]
@@ -52,6 +53,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Exporter\Expose(label: 'name')]
private ?string $name = null;
#[ORM\Column(name: 'number', type: Types::STRING, length: 50, nullable: true)]
#[Constraints\NoSpecialCharacters]
#[Assert\Length(max: 50)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
@@ -141,6 +143,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
* Contact email
*/
#[ORM\Column(name: 'email', type: Types::STRING, length: 75, nullable: true)]
#[Assert\Email(mode: 'html5')]
#[Assert\Length(max: 75)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
@@ -148,6 +151,8 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
private ?string $email = null;
#[ORM\Column(name: 'homepage', type: Types::STRING, length: 100, nullable: true)]
#[Assert\Length(max: 100)]
#[Assert\Url]
#[Assert\NoSuspiciousCharacters]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'homepage')]

View File

@@ -10,6 +10,7 @@
namespace App\Entity;
use App\Form\Type\YesNoType;
use App\Validator\Constraints as Constraints;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
@@ -30,6 +31,7 @@ trait MetaTableTypeTrait
* Name of the meta (custom) field
*/
#[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotNull]
#[Assert\Length(min: 2, max: 50)]
#[Serializer\Expose]

View File

@@ -63,6 +63,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
* Project name
*/
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotNull]
#[Assert\Length(min: 2, max: 150)]
#[Serializer\Expose]
@@ -170,6 +171,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Serializer\Groups(['Default'])]
private bool $globalActivities = true;
#[ORM\Column(name: 'number', type: Types::STRING, length: 10, nullable: true)]
#[Constraints\NoSpecialCharacters]
#[Assert\Length(max: 10)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]

View File

@@ -10,6 +10,7 @@
namespace App\Entity;
use App\Repository\TagRepository;
use App\Validator\Constraints as Constraints;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
@@ -26,7 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert;
class Tag
{
/**
* Internal Tag ID
* Tag ID
*/
#[ORM\Column(name: 'id', type: Types::INTEGER)]
#[ORM\Id]
@@ -35,9 +36,10 @@ class Tag
#[Serializer\Groups(['Default'])]
private ?int $id = null;
/**
* The tag name
* Tag name cannot contain the character: " < > = ,
*/
#[ORM\Column(name: 'name', type: Types::STRING, length: 100, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100, normalizer: 'trim')]
#[Assert\Regex(pattern: '/,/', message: 'Tag name cannot contain comma', match: false)]
@@ -52,10 +54,6 @@ class Tag
use ColorTrait;
public function __construct()
{
}
public function getId(): ?int
{
return $this->id;

View File

@@ -39,6 +39,7 @@ class Team
* Team name
*/
#[ORM\Column(name: 'name', type: Types::STRING, length: 100, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100)]
#[Serializer\Expose]

View File

@@ -81,6 +81,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
*/
#[ORM\Column(name: 'alias', type: Types::STRING, length: 60, nullable: true)]
#[Assert\Length(max: 60)]
#[Constraints\NoSpecialCharacters]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'alias')]
@@ -96,14 +97,16 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
*/
#[ORM\Column(name: 'title', type: Types::STRING, length: 50, nullable: true)]
#[Assert\Length(max: 50)]
#[Constraints\NoSpecialCharacters]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'title')]
private ?string $title = null;
/**
* URL to the user avatar, will be auto-generated if empty
* URL to the user avatar
*/
#[ORM\Column(name: 'avatar', type: Types::STRING, length: 255, nullable: true)]
#[Assert\Url]
#[Assert\Length(max: 255, groups: ['Profile'])]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
@@ -162,6 +165,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[Assert\NotBlank(groups: ['Registration', 'UserCreate', 'Profile'])]
#[Assert\Regex(pattern: '/\//', match: false, groups: ['Registration', 'UserCreate', 'Profile'])]
#[Assert\Length(min: 2, max: 64, groups: ['Registration', 'UserCreate', 'Profile'])]
#[Constraints\NoSpecialCharacters]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private ?string $username = null;
@@ -173,6 +177,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[Serializer\Groups(['Default'])]
private ?string $email = null;
#[ORM\Column(name: 'account', type: Types::STRING, length: 30, nullable: true)]
#[Constraints\NoSpecialCharacters]
#[Assert\Length(max: 30)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]

View File

@@ -10,6 +10,7 @@
namespace App\Entity;
use App\Form\Type\YesNoType;
use App\Validator\Constraints as Constraints;
use App\WorkingTime\Calculator\WorkingTimeCalculatorDay;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
@@ -61,6 +62,7 @@ class UserPreference
#[Assert\NotNull]
private ?User $user = null;
#[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotNull]
#[Assert\Length(min: 2, max: 50)]
#[Serializer\Expose]

View File

@@ -11,12 +11,16 @@ namespace App\Export\Package\CellFormatter;
final class ArrayFormatter implements CellFormatterInterface
{
/**
* @param mixed|array<int, string> $value
* @return mixed
*/
public function formatValue(mixed $value): mixed
{
if (!\is_array($value)) {
throw new \InvalidArgumentException('Only arrays are supported');
}
return implode(', ', $value);
return implode(', ', $value); // @phpstan-ignore argument.type
}
}

View File

@@ -11,6 +11,7 @@ namespace App\Export\Package;
use App\Constants;
use OpenSpout\Common\Entity\Cell;
use OpenSpout\Common\Entity\Cell\StringCell;
use OpenSpout\Common\Entity\Row;
use OpenSpout\Common\Entity\Style\Border;
use OpenSpout\Common\Entity\Style\BorderPart;
@@ -98,7 +99,8 @@ class SpoutSpreadsheet implements SpreadsheetPackage
$style->setShouldWrapText(false);
$style->setShouldShrinkToFit(true);
if (\array_key_exists('totals', $options) && $options['totals'] === true) {
$isTotalsRow = \array_key_exists('totals', $options) && $options['totals'] === true;
if ($isTotalsRow) {
if ($this->writer instanceof CSVWriter) {
return;
}
@@ -109,7 +111,11 @@ class SpoutSpreadsheet implements SpreadsheetPackage
$tmp = [];
$i = 0;
foreach ($columns as $column) {
$tmp[] = Cell::fromValue($column, $this->styles[$i++]); // @phpstan-ignore argument.type
if (!$isTotalsRow && \is_string($column)) {
$tmp[] = new StringCell($column, $style);
} else {
$tmp[] = Cell::fromValue($column, $this->styles[$i++]); // @phpstan-ignore argument.type
}
}
$this->writer->addRow(new Row($tmp, $style));

View File

@@ -26,6 +26,6 @@ final class ArrayFormatter implements CellFormatterInterface
throw new \InvalidArgumentException('Unsupported value given, only array is supported');
}
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), implode(';', $value));
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), implode(';', $value)); // @phpstan-ignore argument.type
}
}

View File

@@ -36,6 +36,7 @@ final class SelectWithApiDataExtension extends AbstractTypeExtension
return;
}
/** @var array{create: string, select: bool, route: string, route_params: array<string, string>, empty_route_params: array<string, string>, reload: string} $apiData */
$apiData = $options['api_data'];
if (!\is_array($apiData)) {

View File

@@ -12,7 +12,6 @@ namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
final class MailType extends AbstractType
{
@@ -20,9 +19,7 @@ final class MailType extends AbstractType
{
$resolver->setDefaults([
'label' => 'email',
'constraints' => [
new Email(['mode' => 'html5'])
],
// no constraint by default, as the form or used entities should add that
]);
}

View File

@@ -25,9 +25,17 @@ final class WeekDaysType extends AbstractType
{
$builder->addModelTransformer(new CallbackTransformer(
function ($weekdays): array {
if ($weekdays === null) {
return [];
}
return explode(',', $weekdays);
},
function ($weekdays): string {
if ($weekdays === null) {
return '';
}
return implode(',', $weekdays);
}
));

View File

@@ -77,7 +77,7 @@ class UserCreateType extends UserEditType
parent::configureOptions($resolver);
$resolver->setDefaults([
'validation_groups' => ['UserCreate', 'Registration'],
'validation_groups' => ['UserCreate', 'Registration', 'Default'],
'include_roles' => false,
'include_teams' => false,
]);

View File

@@ -31,7 +31,7 @@ class UserEditType extends AbstractType
{
use ColorTrait;
public function __construct(private SystemConfiguration $configuration)
public function __construct(private readonly SystemConfiguration $configuration)
{
}
@@ -122,7 +122,7 @@ class UserEditType extends AbstractType
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'validation_groups' => ['Profile'],
'validation_groups' => ['Profile', 'Default'],
'data_class' => User::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',

View File

@@ -67,10 +67,6 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
continue;
}
$replacer = null;
$firstReplacerPos = stripos($value, '${');
if ($firstReplacerPos === false) {
continue;
}
if (stripos($value, '${entry.') !== false) {
if ($sheetValues === false && isset($entries[$entryRow])) {
@@ -94,13 +90,14 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
if (stripos($value, $searchKey) === false) {
continue;
}
if (\is_string($content) && str_starts_with($content, '=')) {
// we ONLY check if the given replacer content contains a formula character
if (\is_string($content) && \in_array($content[0], ['=', '-', '+', '@', "\t", "\r"])) {
$contentLooksLikeFormula = true;
}
$value = str_replace($searchKey, $content ?? '', $value);
}
if ($contentLooksLikeFormula && $firstReplacerPos === 0) {
if ($contentLooksLikeFormula) {
// see https://github.com/kimai/kimai/pull/2054
$cell->setValueExplicit($value, DataType::TYPE_STRING);
} else {

View File

@@ -37,7 +37,7 @@ use Doctrine\ORM\QueryBuilder;
class ActivityRepository extends EntityRepository
{
/**
* @param int[] $activityIds
* @param array<int, string|int> $activityIds
* @return array<Activity>
*/
public function findByIds(array $activityIds): array

View File

@@ -11,10 +11,14 @@ namespace App\Repository;
use App\Entity\User;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @deprecated since 2.54 - see https://www.kimai.org/en/blog/2026/removing-api-passwords
*/
class ApiUserRepository implements UserLoaderInterface, PasswordUpgraderInterface
{
public function __construct(private readonly UserRepository $userRepository)
@@ -23,7 +27,11 @@ class ApiUserRepository implements UserLoaderInterface, PasswordUpgraderInterfac
public function loadUserByIdentifier(string $identifier): ?UserInterface
{
return $this->userRepository->loadUserByIdentifier($identifier);
try {
return $this->userRepository->loadUserByIdentifier($identifier);
} catch (UserNotFoundException $ex) {
return null;
}
}
public function upgradePassword(PasswordAuthenticatedUserInterface|UserInterface $user, string $newHashedPassword): void

View File

@@ -37,7 +37,7 @@ use Doctrine\ORM\QueryBuilder;
class CustomerRepository extends EntityRepository
{
/**
* @param int[] $customerIDs
* @param array<int, string|int> $customerIDs
* @return array<Customer>
*/
public function findByIds(array $customerIDs): array

View File

@@ -40,7 +40,7 @@ use Doctrine\ORM\QueryBuilder;
class ProjectRepository extends EntityRepository
{
/**
* @param int[] $projectIds
* @param array<int, string|int> $projectIds
* @return array<Project>
*/
public function findByIds(array $projectIds): array

View File

@@ -631,11 +631,13 @@ class TimesheetRepository extends EntityRepository
if ($query->hasProjects()) {
$qb->andWhere($qb->expr()->in('t.project', ':project'))
->setParameter('project', $query->getProjectIds());
} elseif ($query->hasCustomers()) {
->setParameter('project', $query->getProjects());
}
if ($query->hasCustomers()) {
$requiresCustomer = true;
$qb->andWhere($qb->expr()->in('p.customer', ':customer'))
->setParameter('customer', $query->getCustomerIds());
->setParameter('customer', $query->getCustomers());
}
$tags = $query->getTags();

View File

@@ -150,7 +150,7 @@ final class SamlProvider
}
if (!empty($results)) {
return implode(' ', $results);
return implode(' ', $results); // @phpstan-ignore argument.type
}
return null;

View File

@@ -24,11 +24,38 @@ final class SamlAuthenticationSuccessHandler extends DefaultAuthenticationSucces
protected function determineTargetUrl(Request $request): string
{
// see https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf
// if using the Deflate encoding, RelayState will be submitted using the query
$relayState = $request->request->get('RelayState', $request->query->get('RelayState'));
if (\is_scalar($relayState)) {
$relayState = (string) $relayState;
if ($relayState !== $this->httpUtils->generateUri($request, (string) $this->options['login_path'])) {
return $relayState;
if (\is_string($relayState)) {
$values = parse_url($relayState);
// we use only the path part of the URL to prevent external redirects
$path = null;
if (\is_array($values) && \array_key_exists('path', $values)) {
$path = $values['path'];
}
if (\is_string($path)
&& $path !== ''
&& str_starts_with($path, '/')
&& !str_starts_with($path, '//')
&& !str_contains($path, '\\')
) {
$target = $this->httpUtils->generateUri($request, $path);
$loginUrl = $this->httpUtils->generateUri($request, (string) $this->options['login_path']);
if (\array_key_exists('scheme', $values) && str_starts_with($values['scheme'], 'http')) {
if (\array_key_exists('host', $values) && !str_starts_with($target, $values['scheme'] . '://' . $values['host'])) {
$target = null;
}
}
// make sure that the login URL is not the target, which would be an endless loop for the user
if ($target !== null && $target !== $loginUrl) {
return $target;
}
}
}

View File

@@ -0,0 +1,58 @@
<?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\Twig;
use App\Entity\User;
use App\WorkingTime\Calculator\WorkingTimeCalculator;
use App\WorkingTime\WorkingTimeService;
use Twig\Extension\AbstractExtension;
use Twig\TwigTest;
final class ContractExtensions extends AbstractExtension
{
/** @var array<string, WorkingTimeCalculator> */
private array $calculators = [];
public function __construct(private readonly WorkingTimeService $workingTimeService)
{
}
public function getTests(): array
{
return [
/* @var array{user: User, date: \DateTimeInterface} $values */
new TwigTest('work_day', function (array $values): bool {
// TODO remove me in 3.0, deprecate with 2.55
if (!\array_key_exists('user', $values) || !\array_key_exists('date', $values)) {
throw new \Exception('Missing variable "user" or "date" to check for "is work_day');
}
return $this->isWorkingDay($values['date'], $values['user']);
}),
new TwigTest('working_day', function (\DateTimeInterface $date, User $user): bool {
return $this->isWorkingDay($date, $user);
}),
];
}
private function isWorkingDay(\DateTimeInterface $date, User $user): bool
{
if ($user->getId() === null) {
return false;
}
$id = 'user_' . $user->getId();
if (!\array_key_exists($id, $this->calculators)) {
$this->calculators[$id] = $this->workingTimeService->getContractMode($user)->getCalculator($user);
}
return $this->calculators[$id]->isWorkDay($date);
}
}

View File

@@ -153,7 +153,12 @@ final class StrictPolicy implements SecurityPolicyInterface
}
if ($obj instanceof User) {
if (\in_array($lcm, ['getpassword', 'gettotpsecret', 'getplainpassword', 'getconfirmationtoken', 'gettotpauthenticationconfiguration'], true)) {
if (str_contains($lcm, 'password')
|| str_contains($lcm, 'totp')
|| str_contains($lcm, 'api')
|| str_contains($lcm, 'secret')
|| str_contains($lcm, 'token')
) {
throw new SecurityNotAllowedMethodError('Tried to access user secrets', User::class, $method);
}
}

View File

@@ -0,0 +1,53 @@
<?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\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class NoSpecialCharacters extends Constraint
{
public const SPECIAL_CHARACTERS_FOUND = 'kimai-html-character-001';
protected const ERROR_NAMES = [
self::SPECIAL_CHARACTERS_FOUND => 'These characters are not allowed: {{ chars }}',
];
/** @var string[] */
public array $characters = [
'<', // XSS
'>', // XSS
'"', // XSS
'=', // DDE
];
public string $message = 'These characters are not allowed: {{ chars }}';
/**
* @param string[]|null $character
*/
public function __construct(
mixed $options = null,
?string $message = null,
?array $character = null,
?array $groups = null,
mixed $payload = null
)
{
parent::__construct($options, $groups, $payload);
$this->message = $message ?? $this->message;
$this->characters = $character ?? $this->characters;
}
public function getTargets(): string
{
return self::PROPERTY_CONSTRAINT;
}
}

View File

@@ -0,0 +1,43 @@
<?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\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class NoSpecialCharactersValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof NoSpecialCharacters)) {
throw new UnexpectedTypeException($constraint, NoSpecialCharacters::class);
}
if (!\is_string($value) || $value === '') {
return;
}
$found = [];
foreach ($constraint->characters as $character) {
if (str_contains($value, $character)) {
$found[] = $character;
}
}
if (\count($found) > 0) {
$this->context->buildViolation(NoSpecialCharacters::getErrorName(NoSpecialCharacters::SPECIAL_CHARACTERS_FOUND))
->setTranslationDomain('validators')
->setParameter('{{ chars }}', implode(' ', $constraint->characters))
->setCode(NoSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
->addViolation();
}
}
}

View File

@@ -10,6 +10,8 @@
namespace App\Voter;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Team;
use App\Entity\User;
use App\Security\RolePermissionManager;
@@ -33,6 +35,7 @@ final class ActivityVoter extends Voter
'time',
'delete',
'permissions',
'access',
];
public function __construct(private readonly RolePermissionManager $permissionManager)
@@ -54,6 +57,37 @@ final class ActivityVoter extends Voter
return $subject instanceof Activity && $this->supportsAttribute($attribute);
}
private function checkTeamPermission(Activity|Project|Customer $subject, User $user): bool
{
if ($user->canSeeAllData()) {
return true;
}
if ($subject instanceof Activity && $subject->getProject() !== null) {
if (!$this->checkTeamPermission($subject->getProject(), $user)) {
return false;
}
}
if ($subject instanceof Project && $subject->getCustomer() !== null) {
if (!$this->checkTeamPermission($subject->getCustomer(), $user)) {
return false;
}
}
if ($subject->getTeams()->count() === 0) {
return true;
}
foreach ($subject->getTeams() as $team) {
if ($user->isInTeam($team)) {
return true;
}
}
return false;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
@@ -62,6 +96,12 @@ final class ActivityVoter extends Voter
return false;
}
// this is a virtual permission, only meant to be used by developer
// it checks if access to the given activity is potentially possible
if ($attribute === 'access') {
return $this->checkTeamPermission($subject, $user);
}
if ($this->permissionManager->hasRolePermission($user, $attribute . '_activity')) {
return true;
}

View File

@@ -9,6 +9,7 @@
namespace App\Voter;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Team;
use App\Entity\User;
@@ -35,6 +36,7 @@ final class ProjectVoter extends Voter
'permissions',
'comments',
'details',
'access',
];
public function __construct(private readonly RolePermissionManager $permissionManager)
@@ -56,6 +58,31 @@ final class ProjectVoter extends Voter
return $subject instanceof Project && $this->supportsAttribute($attribute);
}
private function checkTeamPermission(Project|Customer $subject, User $user): bool
{
if ($user->canSeeAllData()) {
return true;
}
if ($subject instanceof Project && $subject->getCustomer() !== null) {
if (!$this->checkTeamPermission($subject->getCustomer(), $user)) {
return false;
}
}
if ($subject->getTeams()->count() === 0) {
return true;
}
foreach ($subject->getTeams() as $team) {
if ($user->isInTeam($team)) {
return true;
}
}
return false;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
@@ -64,6 +91,12 @@ final class ProjectVoter extends Voter
return false;
}
// this is a virtual permission, only meant to be used by developer
// it checks if access to the given project is potentially possible
if ($attribute === 'access') {
return $this->checkTeamPermission($subject, $user);
}
if ($this->permissionManager->hasRolePermission($user, $attribute . '_project')) {
return true;
}

View File

@@ -24,7 +24,6 @@ final class TeamVoter extends Voter
* support rules based on the given $subject (here: Team)
*/
private const ALLOWED_ATTRIBUTES = [
'view',
'edit',
'delete',
];
@@ -52,17 +51,13 @@ final class TeamVoter extends Voter
{
$user = $token->getUser();
if (!$user instanceof User) {
if (!$user instanceof User || !($subject instanceof Team)) {
return false;
}
switch ($attribute) {
case 'edit':
case 'delete':
// changing existing teams should be limited to admins and teamleads
if (!$user->isAdmin() && !$user->isSuperAdmin() && !$user->isTeamleadOf($subject)) {
return false;
}
// changing existing teams should be limited to admins and teamleads
if (!$user->isAdmin() && !$user->isSuperAdmin() && !$user->isTeamleadOf($subject)) {
return false;
}
return $this->permissionManager->hasRolePermission($user, $attribute . '_team');

View File

@@ -27,7 +27,10 @@ use App\WorkingTime\Model\Year;
use App\WorkingTime\Model\YearPerUserSummary;
use Psr\EventDispatcher\EventDispatcherInterface;
final class WorkingTimeService
/**
* @final not final for mocking in tests
*/
class WorkingTimeService
{
private const LATEST_APPROVAL_PREF = '_latest_approval';
private const LATEST_APPROVAL_FORMAT = 'Y-m-d H:i:s';

View File

@@ -39,7 +39,8 @@
{% elseif type == 'warning' %}
ALERT.warning('{{ message|trans({}, domain)|e('js') }}');
{% elseif type == 'success' %}
// success is expected so we do not need to show messages like "saved successfully"
{# success is expected so we do not need to show messages like "saved successfully" #}
{# THE NEXT LINE NEEDS TO BE KEPT FOR NOW; OTHERWISE THE FLASH MESSAGE WOULD FILL UP THE SESSION #}
// ALERT.success('{{ message|trans({}, domain)|e('js') }}');
{% else %}
ALERT.info('{{ message|trans({}, domain)|e('js') }}');

View File

@@ -163,13 +163,18 @@
{% endembed %}
{% set opcache_warning = not opcache['enabled'] %}
{% embed '@theme/embeds/collapsible.html.twig' with {open: opcache_warning, boxtype: (opcache_warning ? 'danger' : 'success')} %}
{% set opcache_unknown = opcache['unknown'] %}
{% embed '@theme/embeds/collapsible.html.twig' with {open: opcache_warning, boxtype: (opcache_warning ? (opcache_unknown ? 'warning' : 'danger') : 'success')} %}
{% block title %}OPCache{% endblock %}
{% block body_class %}p-0{% endblock %}
{% block body %}
{% import "macros/widgets.html.twig" as widgets %}
{% if opcache_warning %}
{{ widgets.alert('danger', 'Could not detect an active OPCache. This will significantly impact the performance of Kimai.') }}
{% if opcache['unknown'] %}
{{ widgets.alert('warning', 'Could not detect OPCache status. Using shared hosting? Talk to your hoster to find out if it is activated.') }}
{% else %}
{{ widgets.alert('danger', 'Could not detect an active OPCache. This will significantly impact the performance of Kimai.') }}
{% endif %}
{% endif %}
{% if opcache['status'] is iterable %}
<table class="table table-hover">

View File

@@ -2,7 +2,7 @@
<html lang="{{ invoice['invoice.language'] }}">
<head>
<meta charset="utf-8">
<title>{% block title %}{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}{% endblock %}</title>
<title>{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}</title>
<style>
{{ encore_entry_css_source('invoice-pdf')|raw }}
</style>

View File

@@ -2,7 +2,7 @@
<html lang="{{ invoice['invoice.language'] }}">
<head>
<meta charset="utf-8">
<title>{% block title %}{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}{% endblock %}</title>
<title>{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}</title>
<style>
{{ encore_entry_css_source('invoice')|raw }}
</style>

View File

@@ -2,7 +2,7 @@
<html lang="{{ invoice['invoice.language'] }}">
<head>
<meta charset="utf-8">
<title>{% block title %}{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}{% endblock %}</title>
<title>{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}</title>
<style>
{{ encore_entry_css_source('invoice-pdf')|raw }}
</style>

View File

@@ -136,7 +136,6 @@
FETCHER.fetch(url, options)
.then(response => {
response.json().then(json => {
kimai.getPlugin('alert').success('action.update.success');
linkElement.classList.toggle('permOn');
linkElement.classList.toggle('permOff');
toggleLabel(linkElement, !isActive);

View File

@@ -19,6 +19,7 @@
{% block form_body %}
{{ form_start(form) }}
<div class="card-body {% block form_body_class %}{% endblock %}">
{{ form_errors(form) }}
{% block form_pre_content %}{% endblock %}
{% block form_content %}
{{ form_widget(form) }}

View File

@@ -10,6 +10,7 @@
{% form_theme form 'form/horizontal.html.twig' %}
{{ form_errors(form) }}
{{ form_row(form._token) }}
<fieldset class="form-fieldset form-fieldset-light">

View File

@@ -43,6 +43,17 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
};
}
protected function getAuthenticatedUserId(string $role = User::ROLE_USER): int
{
return match ($role) {
User::ROLE_SUPER_ADMIN => 6,
User::ROLE_ADMIN => 5,
User::ROLE_TEAMLEAD => 4,
User::ROLE_USER => 2,
default => throw new \Exception(\sprintf('Unknown role "%s"', $role)),
};
}
protected function createUrl(string $url): string
{
return '/' . ltrim($url, '/');
@@ -215,7 +226,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
/**
* @param Response $response
* @param array<int, string>|array<string, mixed> $failedFields
* @param array<int, string>|array<string, string> $failedFields
* @param bool $extraFields test for the error "This form should not contain extra fields"
* @param array<int, string>|array<string, mixed> $globalError
*/
@@ -793,6 +804,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
self::assertIsString($subResult);
} else {
self::assertIsArray($subResult);
self::assertIsString($value['type']);
if ($value['type'][0] === '@') {
if (empty($result[$key])) {
@@ -807,6 +819,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
break;
case 'object':
self::assertIsString($value['type']);
if ($value['type'][0] === '@') {
if (empty($result[$key])) {
break;

View File

@@ -220,11 +220,24 @@ class ActivityControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertNotEmpty($result);
self::assertEquals(5, \count($result));
self::assertIsArray($result[0]);
self::assertApiResponseTypeStructure('ActivityCollection', $result[0]);
self::assertEquals($imports[0]->getId(), $result[4]['project']);
self::assertEquals($imports[1]->getId(), $result[3]['project']);
self::assertIsArray($result[2]);
self::assertApiResponseTypeStructure('ActivityCollection', $result[2]);
self::assertArrayHasKey('project', $result[2]);
self::assertEquals($imports[1]->getId(), $result[2]['project']);
self::assertIsArray($result[3]);
self::assertApiResponseTypeStructure('ActivityCollection', $result[3]);
self::assertArrayHasKey('project', $result[3]);
self::assertEquals($imports[1]->getId(), $result[3]['project']);
self::assertIsArray($result[4]);
self::assertApiResponseTypeStructure('ActivityCollection', $result[4]);
self::assertArrayHasKey('project', $result[4]);
self::assertEquals($imports[0]->getId(), $result[4]['project']);
}
public function testGetEntityIsSecure(): void

View File

@@ -32,6 +32,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
self::assertStringContainsString('const config = {"basePath":"/api/doc","router":"memory","logo":"/touch-icon-192x192.png","hideInternal":true};', $content);
$results = preg_match('/docs\.apiDescriptionDocument\ \=\ (.*)\.spec;/', $content, $matches);
self::assertNotFalse($results);
self::assertArrayHasKey(1, $matches);
$swaggerJson = json_decode($matches[1], true);
self::assertIsArray($swaggerJson);
self::assertArrayHasKey('spec', $swaggerJson);

View File

@@ -16,8 +16,11 @@ use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
@@ -35,7 +38,12 @@ class TokenAuthenticatorTest extends TestCase
$passwordHasher->method('verify')->willReturn($verify);
$passwordHasherFactory->method('getPasswordHasher')->willReturn($passwordHasher);
return new TokenAuthenticator($userProvider, $passwordHasherFactory);
return new TokenAuthenticator(
$userProvider,
$passwordHasherFactory,
new RateLimiterFactory(['id' => 'foo', 'policy' => 'fixed_window', 'limit' => 10, 'interval' => '1 minute'], new InMemoryStorage()),
new RequestStack(),
);
}
public function testSupports(): void

View File

@@ -171,6 +171,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TeamEntity', $result);
self::assertNotEmpty($result['id']);
self::assertIsArray($result['members']);
self::assertCount(3, $result['members']);
self::assertIsNumeric($updateId);
@@ -182,18 +183,23 @@ class TeamControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result['members']);
self::assertCount(3, $result['members']);
self::assertIsArray($result['members'][0]);
self::assertTrue($result['members'][0]['teamlead']);
self::assertIsArray($result['members'][0]['user']);
self::assertEquals(2, $result['members'][0]['user']['id']);
self::assertEquals('john_user', $result['members'][0]['user']['username']);
self::assertIsArray($result['members'][1]);
self::assertFalse($result['members'][1]['teamlead']);
self::assertIsArray($result['members'][1]['user']);
self::assertEquals(1, $result['members'][1]['user']['id']);
self::assertEquals('clara_customer', $result['members'][1]['user']['username']);
self::assertIsArray($result['members'][2]);
self::assertTrue($result['members'][2]['teamlead']);
self::assertIsArray($result['members'][2]['user']);
self::assertEquals(4, $result['members'][2]['user']['id']);
self::assertEquals('tony_teamlead', $result['members'][2]['user']['username']);
self::assertTrue($result['members'][0]['teamlead']);
self::assertEquals(2, $result['members'][0]['user']['id']);
self::assertEquals('john_user', $result['members'][0]['user']['username']);
}
public function testPatchActionWithValidationErrors(): void
@@ -380,6 +386,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
$result = json_decode($client->getResponse()->getContent(), true);
self::assertIsArray($result);
self::assertIsNumeric($result['id']);
self::assertIsArray($result['customers']);
self::assertCount(0, $result['customers']);
$this->request($client, '/api/teams/' . $result['id'] . '/customers/1', 'POST');
@@ -422,6 +429,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
$result = json_decode($client->getResponse()->getContent(), true);
self::assertIsArray($result);
self::assertIsNumeric($result['id']);
self::assertIsArray($result['customers']);
self::assertCount(1, $result['customers']);
// cannot add existing customer
@@ -445,6 +453,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
$result = json_decode($client->getResponse()->getContent(), true);
self::assertIsArray($result);
self::assertIsNumeric($result['id']);
self::assertIsArray($result['customers']);
self::assertCount(0, $result['customers']);
// add customer
@@ -453,6 +462,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
$result = json_decode($client->getResponse()->getContent(), true);
self::assertIsArray($result);
self::assertIsNumeric($result['id']);
self::assertIsArray($result['customers']);
self::assertCount(1, $result['customers']);
$this->request($client, '/api/teams/' . $result['id'] . '/customers/1', 'DELETE');
@@ -515,6 +525,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
$result = json_decode($client->getResponse()->getContent(), true);
self::assertIsArray($result);
self::assertIsNumeric($result['id']);
self::assertIsArray($result['projects']);
self::assertCount(0, $result['projects']);
$this->request($client, '/api/teams/' . $result['id'] . '/projects/1', 'POST');
self::assertTrue($client->getResponse()->isSuccessful());
@@ -558,6 +569,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
$result = json_decode($client->getResponse()->getContent(), true);
self::assertIsArray($result);
self::assertIsNumeric($result['id']);
self::assertIsArray($result['projects']);
self::assertCount(1, $result['projects']);
// cannot add existing project
@@ -581,6 +593,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
$result = json_decode($client->getResponse()->getContent(), true);
self::assertIsArray($result);
self::assertIsNumeric($result['id']);
self::assertIsArray($result['projects']);
self::assertCount(0, $result['projects']);
// add project
@@ -590,6 +603,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertIsArray($result['projects']);
self::assertCount(1, $result['projects']);
self::assertIsInt($result['id']);
$this->request($client, '/api/teams/' . $result['id'] . '/projects/1', 'DELETE');
self::assertTrue($client->getResponse()->isSuccessful());
@@ -649,6 +663,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
$result = json_decode($client->getResponse()->getContent(), true);
self::assertIsArray($result);
self::assertIsNumeric($result['id']);
self::assertIsArray($result['activities']);
self::assertCount(0, $result['activities']);
$this->request($client, '/api/teams/' . $result['id'] . '/activities/1', 'POST');
self::assertTrue($client->getResponse()->isSuccessful());
@@ -690,6 +705,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
$result = json_decode($client->getResponse()->getContent(), true);
self::assertIsArray($result);
self::assertIsNumeric($result['id']);
self::assertIsArray($result['activities']);
self::assertCount(1, $result['activities']);
// cannot add existing activity
@@ -714,6 +730,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertIsArray($result['activities']);
self::assertCount(0, $result['activities']);
self::assertIsInt($result['id']);
// add activity
$this->request($client, '/api/teams/' . $result['id'] . '/activities/1', 'POST');
@@ -721,6 +738,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
$result = json_decode($client->getResponse()->getContent(), true);
self::assertIsArray($result);
self::assertIsNumeric($result['id']);
self::assertIsArray($result['activities']);
self::assertCount(1, $result['activities']);
$this->request($client, '/api/teams/' . $result['id'] . '/activities/1', 'DELETE');

View File

@@ -35,13 +35,21 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
/**
* @return Timesheet[]
*/
protected function importFixtureForUser(string $role, int $amount = 10): array
protected function importFixtureForUser(User|string $user, int $amount = 10): array
{
$fixture = new TimesheetFixtures($this->getUserByRole($role), $amount);
if (\is_string($user)) {
$role = $user;
$user = $this->getUserByRole($role);
}
$start = DateTimeFactory::createByUser($user)->createDateTime('first day of this month');
$start = $start->setTime(0, 0, 1);
$fixture = new TimesheetFixtures($user, $amount);
$fixture->setFixedRate(true);
$fixture->setHourlyRate(true);
$fixture->setAllowEmptyDescriptions(false);
$fixture->setStartDate((new \DateTime('first day of this month'))->setTime(0, 0, 1));
$fixture->setStartDate($start);
return $this->importFixture($fixture);
}
@@ -212,18 +220,25 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
public function testGetCollectionWithQuery(): void
{
$modifiedAfter = new \DateTime('-1 hour');
$begin = new \DateTime('first day of this month');
$begin->setTime(0, 0, 0);
$end = new \DateTime('last day of this month');
$end->setTime(23, 59, 59);
$role = User::ROLE_USER;
$client = $this->getClientForAuthenticatedUser($role);
$user = $this->getUserByRole($role);
$factory = DateTimeFactory::createByUser($user);
$begin = $factory->createDateTime('first day of this month');
$begin = $begin->setTime(0, 0, 1);
$end = $factory->createDateTime('last day of this month');
$end = $end->setTime(23, 59, 59);
$modifiedAfter = $factory->createDateTime('-20 hour');
$query = [
'customers' => ['1'],
'projects' => ['1'],
'activities' => ['1'],
'page' => 2,
'size' => 4,
'page' => '2',
'size' => '4',
'order' => 'DESC',
'orderBy' => 'rate',
'active' => 0,
@@ -233,8 +248,7 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
'exported' => 0,
];
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->importFixtureForUser(User::ROLE_USER, 22);
$this->importFixtureForUser($user, 22);
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query);
$content = $client->getResponse()->getContent();
self::assertIsString($content);
@@ -250,11 +264,6 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
public function testGetCollectionWithQueryFailsWith404OnOutOfRangedPage(): void
{
$begin = new \DateTime('first day of this month');
$begin->setTime(0, 0, 0);
$end = new \DateTime('last day of this month');
$end->setTime(23, 59, 59);
$query = [
'page' => 19,
'size' => 50,
@@ -268,10 +277,16 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
public function testGetCollectionWithSingleParamsQuery(): void
{
$begin = new \DateTime('first day of this month');
$begin->setTime(0, 0, 0);
$end = new \DateTime('last day of this month');
$end->setTime(23, 59, 59);
$role = User::ROLE_USER;
$client = $this->getClientForAuthenticatedUser($role);
$user = $this->getUserByRole($role);
$factory = DateTimeFactory::createByUser($user);
$begin = $factory->create('first day of this month');
$begin = $begin->setTime(0, 0, 1);
$end = $factory->create('last day of this month');
$end = $end->setTime(23, 59, 59);
$query = [
'customer' => '1',
@@ -287,8 +302,7 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
'exported' => 0,
];
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->importFixtureForUser(User::ROLE_USER);
$this->importFixtureForUser($user);
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query);
$content = $client->getResponse()->getContent();
self::assertIsString($content);
@@ -303,19 +317,23 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
public function testExportedFilter(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->importFixtureForUser(User::ROLE_USER);
$role = User::ROLE_USER;
$client = $this->getClientForAuthenticatedUser($role);
$user = $this->getUserByRole($role);
$factory = DateTimeFactory::createByUser($user);
$this->importFixtureForUser($user);
$fixture = new TimesheetFixtures($this->getUserByRole(User::ROLE_USER), 7);
$fixture = new TimesheetFixtures($user, 7);
$fixture->setExported(true);
$fixture->setStartDate(new \DateTime('first day of this month'));
$fixture->setAllowEmptyDescriptions(false);
$this->importFixture($fixture);
$begin = new \DateTime('first day of this month');
$begin->setTime(0, 0, 0);
$end = new \DateTime('last day of this month');
$end->setTime(23, 59, 59);
$begin = $factory->create('first day of this month');
$begin = $begin->setTime(0, 0, 1);
$end = $factory->create('last day of this month');
$end = $end->setTime(23, 59, 59);
$query = [
'page' => 1,

View File

@@ -403,4 +403,50 @@ class UserControllerTest extends APIControllerBaseTestCase
self::assertEquals('another,testing,bar', $user->getPreferenceValue('metatestmock'));
self::assertEquals('another,testing,bar', $user->getPreferenceValue('metatestmock'));
}
public function testUpdateUserPreferenceWithEnabledPreference(): void
{
$role = User::ROLE_SUPER_ADMIN;
$id = $this->getAuthenticatedUserId($role);
$client = $this->getClientForAuthenticatedUser($role);
$em = $this->getEntityManager();
/** @var User $user */
$user = $em->getRepository(User::class)->find($id);
self::assertEquals(46, $user->getPreferenceValue('hourly_rate'));
$data = [
[
'name' => 'hourly_rate',
'value' => 99
]
];
$this->request($client, '/api/users/' . $id . '/preferences', 'PATCH', [], (string) json_encode($data));
self::assertTrue($client->getResponse()->isSuccessful());
/** @var User $user */
$user = $em->getRepository(User::class)->find($id);
self::assertEquals(99, $user->getPreferenceValue('hourly_rate'));
}
public function testUpdateUserPreferenceWithDisabledPreference(): void
{
$role = User::ROLE_USER;
$id = $this->getAuthenticatedUserId($role);
$client = $this->getClientForAuthenticatedUser($role);
$em = $this->getEntityManager();
/** @var User $user */
$user = $em->getRepository(User::class)->find($id);
self::assertEquals(82, $user->getPreferenceValue('hourly_rate'));
$data = [
[
'name' => 'hourly_rate',
'value' => 99
]
];
$this->request($client, '/api/users/' . $id . '/preferences', 'PATCH', [], (string) json_encode($data));
self::assertApiResponseAccessDenied($client->getResponse());
}
}

View File

@@ -303,6 +303,7 @@ abstract class AbstractControllerBaseTestCase extends WebTestCase
$crawler = $client->request('GET', $this->createUrl($url));
$form = $crawler->filter($formSelector)->form();
if ($disableValidation) {
// needed for setting invalid values e.g. in dropdowns
$form->disableValidation();
}
$result = $client->submit($form, $formData);
@@ -313,7 +314,13 @@ abstract class AbstractControllerBaseTestCase extends WebTestCase
self::assertEquals(
\count($fieldNames),
\count($validationErrors),
\sprintf('Expected %s validation errors, found %s', \count($fieldNames), \count($validationErrors))
\sprintf(
'Expected %s validation errors, found %s. Expected: %s. Found: %s.',
\count($fieldNames),
\count($validationErrors),
implode(',', $fieldNames),
implode(',', $validationErrors->extract(['_text']))
)
);
foreach ($fieldNames as $name) {

View File

@@ -238,11 +238,11 @@ class UserControllerTest extends AbstractControllerBaseTestCase
// invalid fields: username, password_second, email, enabled
[
'user_create' => [
'username' => '',
'plainPassword' => ['first' => 'sdfsdf123'],
'username' => '', // empty
'plainPassword' => ['first' => 'sdfsdf123'], // missing second
'alias' => 'ycvyxcb',
'title' => '34rtwrtewrt',
'email' => '',
'email' => '', // empty email
]
],
[
@@ -251,15 +251,15 @@ class UserControllerTest extends AbstractControllerBaseTestCase
'#user_create_email',
]
],
// invalid fields: username, password, email, enabled
// invalid fields: username, password, email
[
[
'user_create' => [
'username' => 'x',
'plainPassword' => ['first' => 'sdfsdf123', 'second' => 'sdfxxxxxxx'],
'alias' => 'ycvyxcb',
'title' => '34rtwrtewrt',
'email' => 'ydfbvsdfgs',
'username' => 'x', // too short
'plainPassword' => ['first' => 'sdfsdf123', 'second' => 'sdfxxxxxxx'], // do not match
'alias' => 'Boo',
'title' => 'Foo',
'email' => 'ydfbvsdfgs', // invalid email
]
],
[
@@ -283,6 +283,22 @@ class UserControllerTest extends AbstractControllerBaseTestCase
'#user_create_plainPassword_first',
]
],
// invalid fields: alias (special chars), title (special chars, too long)
[
[
'user_create' => [
'username' => 'test1231',
'plainPassword' => ['first' => 'A-Real-Password.1', 'second' => 'A-Real-Password.1'],
'alias' => '""ycvyx<cb""',
'title' => 'sdfgsdfgsd fgsdf "<34rtwrtewrt>" gsdfg sdfg sdfg sdfg sdfg sdfg sdfg sdfgsdfgsd fgsdf gsdfgsgsdfgsdfgsdfgsdfg sdfg sdfg sdfg sdfg sdfg sdfgsdfgsd fgsdf gsdfgsgsdfgsdfgsdfg', // special chars + too long
'email' => 'ydfbvsdfgs@example.com',
]
],
[
'#user_create_alias',
'#user_create_title',
]
],
];
}
}

View File

@@ -87,7 +87,7 @@ final class TimesheetFixtures implements TestFixture
* @param string|\DateTime $date
* @return TimesheetFixtures
*/
public function setStartDate($date): TimesheetFixtures
public function setStartDate(string|\DateTime $date): TimesheetFixtures
{
if (!($date instanceof \DateTime)) {
$date = new \DateTime($date);
@@ -313,8 +313,8 @@ final class TimesheetFixtures implements TestFixture
}
$start = clone $this->startDate;
$start->modify("+ $i days");
$start->modify('+ ' . rand(1, 172800) . ' seconds'); // up to 2 days
$start = $start->modify("+ $i days");
$start = $start->modify('+ ' . rand(1, 172800) . ' seconds'); // up to 2 days
return $start;
}

View File

@@ -412,7 +412,8 @@ class ConfigurationTest extends TestCase
],
'mapping' => [],
'connection' => [
'organization' => []
'organization' => [],
'cleanupLongRelayState' => false
],
'provider' => 'default',
],

View File

@@ -0,0 +1,48 @@
<?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\Form\Type;
use App\Form\Type\WeekDaysType;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Test\TypeTestCase;
#[CoversClass(WeekDaysType::class)]
class WeekDaysTypeTest extends TypeTestCase
{
public function testSubmitNull(): void
{
$data = ['week_days' => null];
$model = new TypeTestModel(['week_days' => null]);
$form = $this->factory->createBuilder(FormType::class, $model);
$form->add('week_days', WeekDaysType::class);
$form = $form->getForm();
$form->submit($data);
self::assertTrue($form->isSynchronized());
self::assertEquals('', $model->offsetGet('week_days'));
}
public function testSubmitValidData(): void
{
$data = ['week_days' => ['tuesday', 'friday', 'sunday']];
$model = new TypeTestModel(['week_days' => null]);
$form = $this->factory->createBuilder(FormType::class, $model);
$form->add('week_days', WeekDaysType::class);
$form = $form->getForm();
$form->submit($data);
self::assertTrue($form->isSynchronized());
self::assertEquals('tuesday,friday,sunday', $model->offsetGet('week_days'));
}
}

View File

@@ -31,7 +31,7 @@ class DebugRendererTest extends TestCase
}
#[DataProvider('getTestModel')]
public function testRender(callable $invoiceModel, $expectedRate, $expectedRows, $expectedDescriptions, $expectedUser1, $expectedUser2, $expectedUser3, $hasProject, $metaFields = []): void
public function testRender(callable $invoiceModel, string $expectedRate, int $expectedRows, int $expectedDescriptions, int $expectedUser1, int $expectedUser2, int $expectedUser3, bool $hasProject, array $metaFields = []): void
{
/** @var InvoiceModel $model */
$model = $invoiceModel($this);
@@ -67,10 +67,12 @@ class DebugRendererTest extends TestCase
$this->assertModelStructure($data['model'], \count($model->getQuery()->getProjects()), \count($model->getQuery()->getActivities()));
$rows = $data['entries'];
self::assertIsArray($rows);
self::assertEquals($expectedRows, \count($rows));
$i = 0;
foreach ($rows as $row) {
self::assertIsArray($row);
$meta = isset($metaFields[$i]) ? $metaFields[$i++] : [];
$this->assertEntryStructure($row, $meta);
}

View File

@@ -42,7 +42,11 @@ class SystemConfigurationFactory
foreach ($iterator as $value) {
$keys = [];
foreach (range(0, $iterator->getDepth()) as $depth) {
$keys[] = $iterator->getSubIterator($depth)->key();
$key = $iterator->getSubIterator($depth)->key();
if (!\is_scalar($key)) {
continue;
}
$keys[] = $key;
}
$newConfig[implode('.', $keys)] = $value;
}

View File

@@ -22,12 +22,37 @@ use Symfony\Component\Security\Http\HttpUtils;
#[CoversClass(SamlAuthenticationSuccessHandler::class)]
class SamlAuthenticationSuccessHandlerTest extends TestCase
{
public function testRelayStateWithInvalidHost(): void
{
$handler = new SamlAuthenticationSuccessHandler(new HttpUtils($this->getUrlGenerator()));
$response = $handler->onAuthenticationSuccess($this->getRequest('http://example.com/sso/login', 'https://localhost/relayed'), $this->getSamlToken());
self::assertInstanceOf(RedirectResponse::class, $response);
$target = $response->getTargetUrl();
$expected = 'http://example.com/';
self::assertEquals($expected, $target);
self::assertTrue($response->isRedirect($target));
}
public function testRelayState(): void
{
$handler = new SamlAuthenticationSuccessHandler(new HttpUtils($this->getUrlGenerator()));
$response = $handler->onAuthenticationSuccess($this->getRequest('/sso/login', 'http://localhost/relayed'), $this->getSamlToken());
$response = $handler->onAuthenticationSuccess($this->getRequest('/sso/login', '/relayed'), $this->getSamlToken());
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertTrue($response->isRedirect('http://localhost/relayed'));
$target = $response->getTargetUrl();
$expected = 'http://localhost/relayed';
self::assertEquals($expected, $target);
self::assertTrue($response->isRedirect($target));
}
public function testRelayStateWithFullUrls(): void
{
$handler = new SamlAuthenticationSuccessHandler(new HttpUtils($this->getUrlGenerator()));
$response = $handler->onAuthenticationSuccess($this->getRequest('http://example.com/sso/login', 'http://example.com/relayed/123'), $this->getSamlToken());
self::assertInstanceOf(RedirectResponse::class, $response);
$target = $response->getTargetUrl();
$expected = 'http://example.com/relayed/123';
self::assertEquals($expected, $target);
self::assertTrue($response->isRedirect($target));
}
public function testWithoutRelayState(): void
@@ -71,7 +96,7 @@ class SamlAuthenticationSuccessHandlerTest extends TestCase
$params['RelayState'] = $relayState;
}
return Request::create($path, 'get', $params);
return Request::create($path, 'post', $params);
}
private function getSamlToken(): SamlToken

View File

@@ -0,0 +1,53 @@
<?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\Twig;
use App\Twig\ContractExtensions;
use App\WorkingTime\WorkingTimeService;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Twig\TwigTest;
#[CoversClass(ContractExtensions::class)]
class ContractExtensionsTest extends TestCase
{
private function getSut(): ContractExtensions
{
$service = $this->createMock(WorkingTimeService::class);
$sut = new ContractExtensions($service);
return $sut;
}
public function testDefinedMethods(): void
{
self::assertCount(2, $this->getSut()->getTests());
self::assertCount(0, $this->getSut()->getFilters());
self::assertCount(0, $this->getSut()->getFunctions());
}
public function testGetTests(): void
{
$filters = [
'work_day',
'working_day',
];
$i = 0;
$sut = $this->getSut();
$twigFilters = $sut->getTests();
foreach ($twigFilters as $filter) {
self::assertInstanceOf(TwigTest::class, $filter);
self::assertEquals($filters[$i++], $filter->getName());
}
}
}

View File

@@ -92,6 +92,8 @@ class StrictPolicyTestCase extends TestCase
[new User(), 'getPlainPassword', 'Tried to access user secrets'],
[new User(), 'getConfirmationToken', 'Tried to access user secrets'],
[new User(), 'getTotpAuthenticationConfiguration', 'Tried to access user secrets'],
[new User(), 'getPlainApiToken', 'Tried to access user secrets'],
[new User(), 'getApiToken', 'Tried to access user secrets'],
];
}
}

View File

@@ -0,0 +1,88 @@
<?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\Validator\Constraints;
use App\Validator\Constraints\NoSpecialCharacters;
use App\Validator\Constraints\NoSpecialCharactersValidator;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @extends ConstraintValidatorTestCase<NoSpecialCharactersValidator>
*/
#[CoversClass(NoSpecialCharacters::class)]
#[CoversClass(NoSpecialCharactersValidator::class)]
class NoSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): NoSpecialCharactersValidator
{
return new NoSpecialCharactersValidator();
}
public function testConstraintIsInvalid(): void
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate('foo', new NotBlank());
}
public function testGetTargets(): void
{
$constraint = new NoSpecialCharacters();
self::assertEquals('property', $constraint->getTargets());
}
public static function getValidTestData(): array
{
return [
[''],
[null],
['asdf-.,123!§$%&/()?`4567\'890ß'],
];
}
#[DataProvider('getValidTestData')]
public function testValidInput(string|null $data): void
{
$this->validator = $this->createValidator();
$this->validator->initialize($this->context);
$this->validator->validate($data, new NoSpecialCharacters());
$this->assertNoViolation();
}
public static function getInvalidTestData(): array
{
return [
['Test" onclick="alert(1)"'],
['Test><a href=#>Foo</a>'],
['Test" broken string'],
['I am not = allowed'],
];
}
#[DataProvider('getInvalidTestData')]
public function testInvalidInput(string|null $data): void
{
$this->validator = $this->createValidator();
$this->validator->initialize($this->context);
$this->validator->validate($data, new NoSpecialCharacters());
$this->buildViolation('These characters are not allowed: {{ chars }}')
->setCode(NoSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
->setParameter('{{ chars }}', '< > " =')
->assertRaised();
}
}

Some files were not shown because too many files have changed in this diff Show More