Release 2.54 (#5896)

This commit is contained in:
Kevin Papst
2026-04-13 21:22:06 +02:00
committed by GitHub
parent 16703081cd
commit bad92d7215
30 changed files with 232 additions and 91 deletions

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

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

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

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

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

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'])]

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,7 +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]
#[Constraints\NoSpecialCharacters]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'alias')]
@@ -97,15 +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\NoHtmlSpecialCharacters]
#[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'])]
@@ -164,7 +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\NoHtmlSpecialCharacters]
#[Constraints\NoSpecialCharacters]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private ?string $username = null;
@@ -176,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,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
{
try {
return $this->userRepository->loadUserByIdentifier($identifier);
} catch (UserNotFoundException $ex) {
return null;
}
}
public function upgradePassword(PasswordAuthenticatedUserInterface|UserInterface $user, string $newHashedPassword): void

View File

@@ -29,7 +29,21 @@ final class ContractExtensions extends AbstractExtension
return [
/* @var array{user: User, date: \DateTimeInterface} $values */
new TwigTest('work_day', function (array $values): bool {
$user = $values['user'];
// 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;
}
@@ -39,10 +53,6 @@ final class ContractExtensions extends AbstractExtension
$this->calculators[$id] = $this->workingTimeService->getContractMode($user)->getCalculator($user);
}
$date = $values['date'];
return $this->calculators[$id]->isWorkDay($date);
}),
];
}
}

View File

@@ -1,40 +0,0 @@
<?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

@@ -12,7 +12,7 @@ namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class NoHtmlSpecialCharacters extends Constraint
final class NoSpecialCharacters extends Constraint
{
public const SPECIAL_CHARACTERS_FOUND = 'kimai-html-character-001';
@@ -20,8 +20,32 @@ final class NoHtmlSpecialCharacters extends Constraint
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

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

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

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

@@ -29,7 +29,7 @@ class ContractExtensionsTest extends TestCase
public function testDefinedMethods(): void
{
self::assertCount(1, $this->getSut()->getTests());
self::assertCount(2, $this->getSut()->getTests());
self::assertCount(0, $this->getSut()->getFilters());
self::assertCount(0, $this->getSut()->getFunctions());
}
@@ -38,6 +38,7 @@ class ContractExtensionsTest extends TestCase
{
$filters = [
'work_day',
'working_day',
];
$i = 0;

View File

@@ -9,8 +9,8 @@
namespace App\Tests\Validator\Constraints;
use App\Validator\Constraints\NoHtmlSpecialCharacters;
use App\Validator\Constraints\NoHtmlSpecialCharactersValidator;
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;
@@ -18,15 +18,15 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @extends ConstraintValidatorTestCase<NoHtmlSpecialCharactersValidator>
* @extends ConstraintValidatorTestCase<NoSpecialCharactersValidator>
*/
#[CoversClass(NoHtmlSpecialCharacters::class)]
#[CoversClass(NoHtmlSpecialCharactersValidator::class)]
class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
#[CoversClass(NoSpecialCharacters::class)]
#[CoversClass(NoSpecialCharactersValidator::class)]
class NoSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): NoHtmlSpecialCharactersValidator
protected function createValidator(): NoSpecialCharactersValidator
{
return new NoHtmlSpecialCharactersValidator();
return new NoSpecialCharactersValidator();
}
public function testConstraintIsInvalid(): void
@@ -38,7 +38,7 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
public function testGetTargets(): void
{
$constraint = new NoHtmlSpecialCharacters();
$constraint = new NoSpecialCharacters();
self::assertEquals('property', $constraint->getTargets());
}
@@ -47,7 +47,7 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
return [
[''],
[null],
['asdf-.,123!§$%&/()=?`4567\'890ß'],
['asdf-.,123!§$%&/()?`4567\'890ß'],
];
}
@@ -57,7 +57,7 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
$this->validator = $this->createValidator();
$this->validator->initialize($this->context);
$this->validator->validate($data, new NoHtmlSpecialCharacters());
$this->validator->validate($data, new NoSpecialCharacters());
$this->assertNoViolation();
}
@@ -68,6 +68,7 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
['Test" onclick="alert(1)"'],
['Test><a href=#>Foo</a>'],
['Test" broken string'],
['I am not = allowed'],
];
}
@@ -77,11 +78,11 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
$this->validator = $this->createValidator();
$this->validator->initialize($this->context);
$this->validator->validate($data, new NoHtmlSpecialCharacters());
$this->validator->validate($data, new NoSpecialCharacters());
$this->buildViolation('These characters are not allowed: {{ chars }}')
->setCode(NoHtmlSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
->setParameter('{{ chars }}', '< " >')
->setCode(NoSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
->setParameter('{{ chars }}', '< > " =')
->assertRaised();
}
}

View File

@@ -27,6 +27,26 @@ parameters:
-
message: "#^PHPDoc tag @var with type App\\\\(.*) is not subtype of native type PHPUnit\\\\Framework\\\\MockObject\\\\MockObject\\.$#"
-
identifier: classConstant.deprecatedClass
count: 2
path: API/Authentication/TokenAuthenticatorTest.php
-
identifier: new.deprecatedClass
count: 1
path: API/Authentication/TokenAuthenticatorTest.php
-
identifier: return.deprecatedClass
count: 1
path: API/Authentication/TokenAuthenticatorTest.php
-
identifier: method.deprecatedClass
count: 13
path: API/Authentication/TokenAuthenticatorTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\APIControllerBaseTestCase\\:\\:assertApiException\\(\\) has parameter \\$expectedErrors with no value type specified in iterable type array\\.$#"
count: 1