Release 2.53 (#5878)

This commit is contained in:
Kevin Papst
2026-04-10 18:09:27 +02:00
committed by GitHub
parent fe4185ae45
commit 999d820d4c
79 changed files with 1046 additions and 430 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 = {
'&': '&',
'<': '&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

@@ -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

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

@@ -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

@@ -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

@@ -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

@@ -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.53.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 25200;
public const VERSION_ID = 25300;
/**
* 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

@@ -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

@@ -141,6 +141,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 +149,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

@@ -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\NoHtmlSpecialCharacters]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'alias')]
@@ -96,6 +97,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
*/
#[ORM\Column(name: 'title', type: Types::STRING, length: 50, nullable: true)]
#[Assert\Length(max: 50)]
#[Constraints\NoHtmlSpecialCharacters]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'title')]
@@ -162,6 +164,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\NoHtmlSpecialCharacters]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private ?string $username = null;

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

@@ -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

@@ -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

@@ -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,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\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 {
$user = $values['user'];
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);
}
$date = $values['date'];
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,29 @@
<?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 NoHtmlSpecialCharacters 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 }}',
];
public string $message = 'These characters are not allowed: {{ chars }}';
public function getTargets(): string
{
return self::PROPERTY_CONSTRAINT;
}
}

View File

@@ -0,0 +1,40 @@
<?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 NoHtmlSpecialCharactersValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof NoHtmlSpecialCharacters)) {
throw new UnexpectedTypeException($constraint, NoHtmlSpecialCharacters::class);
}
if (!\is_string($value)) {
return;
}
if (str_contains($value, '<')
|| str_contains($value, '>')
|| str_contains($value, '"')
// there are many family names that use the ' (like O'Hara), so we cannot forbid them
) {
$this->context->buildViolation(NoHtmlSpecialCharacters::getErrorName(NoHtmlSpecialCharacters::SPECIAL_CHARACTERS_FOUND))
->setTranslationDomain('validators')
->setParameter('{{ chars }}', '< " >')
->setCode(NoHtmlSpecialCharacters::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

@@ -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

@@ -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

@@ -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

@@ -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,52 @@
<?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(1, $this->getSut()->getTests());
self::assertCount(0, $this->getSut()->getFilters());
self::assertCount(0, $this->getSut()->getFunctions());
}
public function testGetTests(): void
{
$filters = [
'work_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,87 @@
<?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\NoHtmlSpecialCharacters;
use App\Validator\Constraints\NoHtmlSpecialCharactersValidator;
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<NoHtmlSpecialCharactersValidator>
*/
#[CoversClass(NoHtmlSpecialCharacters::class)]
#[CoversClass(NoHtmlSpecialCharactersValidator::class)]
class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): NoHtmlSpecialCharactersValidator
{
return new NoHtmlSpecialCharactersValidator();
}
public function testConstraintIsInvalid(): void
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate('foo', new NotBlank());
}
public function testGetTargets(): void
{
$constraint = new NoHtmlSpecialCharacters();
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 NoHtmlSpecialCharacters());
$this->assertNoViolation();
}
public static function getInvalidTestData(): array
{
return [
['Test" onclick="alert(1)"'],
['Test><a href=#>Foo</a>'],
['Test" broken string'],
];
}
#[DataProvider('getInvalidTestData')]
public function testInvalidInput(string|null $data): void
{
$this->validator = $this->createValidator();
$this->validator->initialize($this->context);
$this->validator->validate($data, new NoHtmlSpecialCharacters());
$this->buildViolation('These characters are not allowed: {{ chars }}')
->setCode(NoHtmlSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
->setParameter('{{ chars }}', '< " >')
->assertRaised();
}
}

View File

@@ -107,11 +107,6 @@ parameters:
count: 1
path: API/APIControllerBaseTestCase.php
-
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, mixed given\\.$#"
count: 1
path: API/APIControllerBaseTestCase.php
-
message: "#^Parameter \\#1 \\$json of function json_decode expects string, string\\|false given\\.$#"
count: 3
@@ -122,11 +117,6 @@ parameters:
count: 1
path: API/APIControllerBaseTestCase.php
-
message: "#^Parameter \\#2 \\$string of function explode expects string, mixed given\\.$#"
count: 1
path: API/APIControllerBaseTestCase.php
-
message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
count: 1
@@ -1461,46 +1451,6 @@ parameters:
count: 1
path: Invoice/Renderer/DebugRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedDescriptions with no type specified\\.$#"
count: 1
path: Invoice/Renderer/DebugRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedRate with no type specified\\.$#"
count: 1
path: Invoice/Renderer/DebugRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedRows with no type specified\\.$#"
count: 1
path: Invoice/Renderer/DebugRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser1 with no type specified\\.$#"
count: 1
path: Invoice/Renderer/DebugRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser2 with no type specified\\.$#"
count: 1
path: Invoice/Renderer/DebugRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser3 with no type specified\\.$#"
count: 1
path: Invoice/Renderer/DebugRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:testRender\\(\\) has parameter \\$hasProject with no type specified\\.$#"
count: 1
path: Invoice/Renderer/DebugRendererTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:testRender\\(\\) has parameter \\$metaFields with no type specified\\.$#"
count: 1
path: Invoice/Renderer/DebugRendererTest.php
-
message: "#^Parameter \\#1 \\$json of function json_decode expects string, string\\|false given\\.$#"
count: 1

View File

@@ -166,6 +166,10 @@
<source>Duration cannot be negative.</source>
<target state="translated">Die Dauer darf nicht negativ sein.</target>
</trans-unit>
<trans-unit id="EDEDQtZ" resname="These characters are not allowed: {{ chars }}">
<source>These characters are not allowed: {{ chars }}</source>
<target>Die folgenden Zeichen sind nicht erlaubt: {{ chars }}</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -166,6 +166,10 @@
<source>Duration cannot be negative.</source>
<target>Duration cannot be negative.</target>
</trans-unit>
<trans-unit id="EDEDQtZ" resname="These characters are not allowed: {{ chars }}">
<source>These characters are not allowed: {{ chars }}</source>
<target>The following characters are not allowed: {{ chars }}</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1880,6 +1880,13 @@ __metadata:
languageName: node
linkType: hard
"@types/trusted-types@npm:^2.0.7":
version: 2.0.7
resolution: "@types/trusted-types@npm:2.0.7"
checksum: 10c0/4c4855f10de7c6c135e0d32ce462419d8abbbc33713b31d294596c0cc34ae1fa6112a2f9da729c8f7a20707782b0d69da3b1f8df6645b0366d08825ca1522e0c
languageName: node
linkType: hard
"@types/yargs-parser@npm:*":
version: 21.0.3
resolution: "@types/yargs-parser@npm:21.0.3"
@@ -2875,6 +2882,18 @@ __metadata:
languageName: node
linkType: hard
"dompurify@npm:^3":
version: 3.3.3
resolution: "dompurify@npm:3.3.3"
dependencies:
"@types/trusted-types": "npm:^2.0.7"
dependenciesMeta:
"@types/trusted-types":
optional: true
checksum: 10c0/097c14a21a3f6cb95beded9ecd255f7c3512c42767b048390c747b0fe35736f6a71e02320fc50a9ac2be645834b463e4760915d595d502a56452daf339d0ea9c
languageName: node
linkType: hard
"domutils@npm:^2.5.2, domutils@npm:^2.8.0":
version: 2.8.0
resolution: "domutils@npm:2.8.0"
@@ -3706,6 +3725,7 @@ __metadata:
bootstrap: "npm:^5.3"
chart.js: "npm:^4"
core-js: "npm:^3"
dompurify: "npm:^3"
eslint: "npm:^9"
globals: "npm:^15"
gridstack: "npm:^7"