Release 2.26 (#5189)

* bring back deprecated methods
* bump packages
* fix SAML redirect
* config flag for break times
* use class constant instead of string in attributes
* throw if all tags were not found - fixes #4792
This commit is contained in:
Kevin Papst
2024-12-05 10:42:07 +01:00
committed by GitHub
parent 70741eebfd
commit 82a3b99a31
39 changed files with 437 additions and 397 deletions

463
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -697,11 +697,6 @@ parameters:
count: 1
path: src/Controller/ActivityController.php
-
message: "#^Parameter \\#1 \\$returnTo of method OneLogin\\\\Saml2\\\\Auth\\:\\:login\\(\\) expects string\\|null, mixed given\\.$#"
count: 1
path: src/Controller/Auth/SamlController.php
-
message: "#^Cannot access offset 'user' on mixed\\.$#"
count: 1
@@ -3872,11 +3867,6 @@ parameters:
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Method App\\\\Repository\\\\TimesheetRepository\\:\\:getRawData\\(\\) should return array but returns mixed\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Parameter \\#1 \\$amountThisMonth of method App\\\\Model\\\\TimesheetStatistic\\:\\:setAmountThisMonth\\(\\) expects float\\|int, mixed given\\.$#"
count: 1
@@ -3972,11 +3962,6 @@ parameters:
count: 1
path: src/Saml/Security/SamlAuthenticationFailureHandler.php
-
message: "#^Method App\\\\Saml\\\\Security\\\\SamlAuthenticationSuccessHandler\\:\\:determineTargetUrl\\(\\) should return string but returns mixed\\.$#"
count: 1
path: src/Saml/Security/SamlAuthenticationSuccessHandler.php
-
message: "#^Property App\\\\Saml\\\\Security\\\\SamlAuthenticationSuccessHandler\\:\\:\\$defaultOptions has no type specified\\.$#"
count: 1

View File

@@ -37,6 +37,7 @@ use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints;
@@ -181,8 +182,11 @@ final class TimesheetController extends BaseApiController
/** @var array<string> $tags */
$tags = $paramFetcher->get('tags');
if (\is_array($tags) && \count($tags) > 0) {
$tags = $this->tagRepository->findTagsByName($tags, true);
foreach ($tags as $tag) {
$tagsByName = $this->tagRepository->findTagsByName($tags, true);
if (\count($tagsByName) === 0) {
throw new BadRequestHttpException('Given tags were not found');
}
foreach ($tagsByName as $tag) {
$query->addTag($tag);
}
}

View File

@@ -26,7 +26,7 @@ use Symfony\Component\Intl\Locales;
*
* @codeCoverageIgnore
*/
#[AsCommand(name: 'kimai:reset:locales')]
#[AsCommand(name: 'kimai:reset:locales', description: 'Regenerate the locale definition file')]
final class RegenerateLocalesCommand extends Command
{
/**
@@ -66,11 +66,6 @@ final class RegenerateLocalesCommand extends Command
return $this->kernelEnvironment !== 'prod';
}
protected function configure(): void
{
$this->setDescription('Regenerate the locale definition file');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.25.0';
public const VERSION = '2.26.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 22500;
public const VERSION_ID = 22600;
/**
* The software name
*/

View File

@@ -12,18 +12,24 @@ namespace App\Controller\Auth;
use App\Configuration\SamlConfigurationInterface;
use App\Saml\SamlAuthFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
#[Route(path: '/saml')]
final class SamlController extends AbstractController
{
use TargetPathTrait;
public function __construct(
private readonly SamlAuthFactory $authFactory,
private readonly SamlConfigurationInterface $samlConfiguration
private readonly SamlConfigurationInterface $samlConfiguration,
private readonly Security $security,
)
{
}
@@ -54,8 +60,12 @@ final class SamlController extends AbstractController
throw new \RuntimeException($error);
}
// this does set headers and exit as $stay is not set to true
$redirectTarget = $session->get('_security.main.target_path');
$firewallName = $this->security->getFirewallConfig($request)?->getName();
if ($firewallName === null || $firewallName === '') {
throw new ServiceUnavailableHttpException(message: 'Unknown firewall.');
}
$redirectTarget = $this->getTargetPath($session, $firewallName);
if ($redirectTarget === null || $redirectTarget === '') {
$redirectTarget = $this->generateUrl('homepage', [], UrlGeneratorInterface::ABSOLUTE_URL);
}

View File

@@ -253,7 +253,6 @@ final class DoctorController extends AbstractController
'max_execution_time',
'date.timezone',
'allow_url_fopen',
'allow_url_include',
'default_charset',
'default_mimetype',
'display_errors',

View File

@@ -331,6 +331,9 @@ final class Configuration implements ConfigurationInterface
->booleanNode('require_activity')
->defaultTrue()
->end()
->booleanNode('break_time_active')
->defaultFalse()
->end()
->end()
->end()
->end()

View File

@@ -9,12 +9,13 @@
namespace App\Entity;
use App\Repository\AccessTokenRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_access_token')]
#[ORM\Entity(repositoryClass: 'App\Repository\AccessTokenRepository')]
#[ORM\Entity(repositoryClass: AccessTokenRepository::class)]
#[ORM\UniqueConstraint(columns: ['token'])]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(fields: ['token'])]

View File

@@ -12,6 +12,7 @@ namespace App\Entity;
use App\Doctrine\Behavior\CreatedAt;
use App\Doctrine\Behavior\CreatedTrait;
use App\Export\Annotation as Exporter;
use App\Repository\ActivityRepository;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -24,7 +25,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Index(columns: ['visible', 'project_id'])]
#[ORM\Index(columns: ['visible', 'project_id', 'name'])]
#[ORM\Index(columns: ['visible', 'name'])]
#[ORM\Entity(repositoryClass: 'App\Repository\ActivityRepository')]
#[ORM\Entity(repositoryClass: ActivityRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[Serializer\ExclusionPolicy('all')]
#[Serializer\VirtualProperty('ProjectName', exp: 'object.getProject() === null ? null : object.getProject().getName()', options: [new Serializer\SerializedName('parentTitle'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Activity'])])]

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use App\Repository\ActivityRateRepository;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@@ -16,7 +17,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_activities_rates')]
#[ORM\UniqueConstraint(columns: ['user_id', 'activity_id'])]
#[ORM\Entity(repositoryClass: 'App\Repository\ActivityRateRepository')]
#[ORM\Entity(repositoryClass: ActivityRateRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(['user', 'activity'], ignoreNull: false)]
#[Serializer\ExclusionPolicy('all')]

View File

@@ -9,13 +9,14 @@
namespace App\Entity;
use App\Repository\BookmarkRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_bookmarks')]
#[ORM\UniqueConstraint(columns: ['user_id', 'name'])]
#[ORM\Entity(repositoryClass: 'App\Repository\BookmarkRepository')]
#[ORM\Entity(repositoryClass: BookmarkRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(fields: ['user', 'name'])]
class Bookmark

View File

@@ -9,13 +9,14 @@
namespace App\Entity;
use App\Repository\ConfigurationRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_configuration')]
#[ORM\UniqueConstraint(columns: ['name'])]
#[ORM\Entity(repositoryClass: 'App\Repository\ConfigurationRepository')]
#[ORM\Entity(repositoryClass: ConfigurationRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
class Configuration

View File

@@ -12,6 +12,7 @@ namespace App\Entity;
use App\Doctrine\Behavior\CreatedAt;
use App\Doctrine\Behavior\CreatedTrait;
use App\Export\Annotation as Exporter;
use App\Repository\CustomerRepository;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -22,7 +23,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_customers')]
#[ORM\Index(columns: ['visible'])]
#[ORM\Entity(repositoryClass: 'App\Repository\CustomerRepository')]
#[ORM\Entity(repositoryClass: CustomerRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[Serializer\ExclusionPolicy('all')]
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable'])]

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use App\Repository\CustomerRateRepository;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@@ -16,7 +17,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_customers_rates')]
#[ORM\UniqueConstraint(columns: ['user_id', 'customer_id'])]
#[ORM\Entity(repositoryClass: 'App\Repository\CustomerRateRepository')]
#[ORM\Entity(repositoryClass: CustomerRateRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(['user', 'customer'], ignoreNull: false)]
#[Serializer\ExclusionPolicy('all')]

View File

@@ -11,17 +11,19 @@ namespace App\Entity;
use App\Export\Annotation as Exporter;
use App\Invoice\InvoiceModel;
use App\Repository\InvoiceRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use OpenApi\Attributes as OA;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_invoices')]
#[ORM\UniqueConstraint(columns: ['invoice_number'])]
#[ORM\UniqueConstraint(columns: ['invoice_filename'])]
#[ORM\Entity(repositoryClass: 'App\Repository\InvoiceRepository')]
#[ORM\Entity(repositoryClass: InvoiceRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('invoiceNumber')]
#[UniqueEntity('invoiceFilename')]
@@ -65,12 +67,14 @@ class Invoice implements EntityWithMetaFields
#[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[OA\Property(ref: '#/components/schemas/Customer')]
private ?Customer $customer = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[OA\Property(ref: '#/components/schemas/User')]
private ?User $user = null;
#[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)]
#[Assert\NotNull]

View File

@@ -9,13 +9,14 @@
namespace App\Entity;
use App\Repository\InvoiceTemplateRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_invoice_templates')]
#[ORM\UniqueConstraint(columns: ['name'])]
#[ORM\Entity(repositoryClass: 'App\Repository\InvoiceTemplateRepository')]
#[ORM\Entity(repositoryClass: InvoiceTemplateRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
class InvoiceTemplate

View File

@@ -12,6 +12,7 @@ namespace App\Entity;
use App\Doctrine\Behavior\CreatedAt;
use App\Doctrine\Behavior\CreatedTrait;
use App\Export\Annotation as Exporter;
use App\Repository\ProjectRepository;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -23,7 +24,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_projects')]
#[ORM\Index(columns: ['customer_id', 'visible', 'name'])]
#[ORM\Index(columns: ['customer_id', 'visible', 'id'])]
#[ORM\Entity(repositoryClass: 'App\Repository\ProjectRepository')]
#[ORM\Entity(repositoryClass: ProjectRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[Serializer\ExclusionPolicy('all')]
#[Serializer\VirtualProperty('CustomerName', exp: 'object.getCustomer() === null ? null : object.getCustomer().getName()', options: [new Serializer\SerializedName('parentTitle'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Project'])])]

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use App\Repository\ProjectRateRepository;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@@ -16,7 +17,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_projects_rates')]
#[ORM\UniqueConstraint(columns: ['user_id', 'project_id'])]
#[ORM\Entity(repositoryClass: 'App\Repository\ProjectRateRepository')]
#[ORM\Entity(repositoryClass: ProjectRateRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(['user', 'project'], ignoreNull: false)]
#[Serializer\ExclusionPolicy('all')]

View File

@@ -9,13 +9,14 @@
namespace App\Entity;
use App\Repository\RoleRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_roles')]
#[ORM\UniqueConstraint(name: 'roles_name', columns: ['name'])]
#[ORM\Entity(repositoryClass: 'App\Repository\RoleRepository')]
#[ORM\Entity(repositoryClass: RoleRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
class Role

View File

@@ -9,13 +9,14 @@
namespace App\Entity;
use App\Repository\RolePermissionRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_roles_permissions')]
#[ORM\UniqueConstraint(name: 'role_permission', columns: ['role_id', 'permission'])]
#[ORM\Entity(repositoryClass: 'App\Repository\RolePermissionRepository')]
#[ORM\Entity(repositoryClass: RolePermissionRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(['role', 'permission'])]
class RolePermission

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use App\Repository\TagRepository;
use App\Utils\Color;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
@@ -17,7 +18,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_tags')]
#[ORM\UniqueConstraint(columns: ['name'])]
#[ORM\Entity(repositoryClass: 'App\Repository\TagRepository')]
#[ORM\Entity(repositoryClass: TagRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
#[Serializer\ExclusionPolicy('all')]

View File

@@ -11,6 +11,7 @@ namespace App\Entity;
use App\Doctrine\Behavior\ModifiedAt;
use App\Doctrine\Behavior\ModifiedTrait;
use App\Repository\TimesheetRepository;
use App\Validator\Constraints as Constraints;
use DateTime;
use DateTimeZone;
@@ -37,7 +38,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Index(columns: ['end_time', 'user', 'start_time'], name: 'IDX_TIMESHEET_TICKTAC')]
#[ORM\Index(columns: ['user', 'project_id', 'activity_id'], name: 'IDX_TIMESHEET_RECENT_ACTIVITIES')]
#[ORM\Index(columns: ['user', 'id', 'duration'], name: 'IDX_TIMESHEET_RESULT_STATS')]
#[ORM\Entity(repositoryClass: 'App\Repository\TimesheetRepository')]
#[ORM\Entity(repositoryClass: TimesheetRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[ORM\HasLifecycleCallbacks]
#[Serializer\ExclusionPolicy('all')]

View File

@@ -10,6 +10,7 @@
namespace App\Entity;
use App\Export\Annotation as Exporter;
use App\Repository\UserRepository;
use App\Utils\StringHelper;
use App\Validator\Constraints as Constraints;
use App\WorkingTime\Mode\WorkingTimeModeNone;
@@ -33,7 +34,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_users')]
#[ORM\UniqueConstraint(columns: ['username'])]
#[ORM\UniqueConstraint(columns: ['email'])]
#[ORM\Entity(repositoryClass: 'App\Repository\UserRepository')]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('username')]
#[UniqueEntity('email')]

View File

@@ -66,6 +66,35 @@ class WorkingTimeRepository extends EntityRepository
return $qb->getQuery()->getResult();
}
/**
* @deprecated since 2.25.0 - kept for BC with old plugin versions
*/
public function getLatestApproval(User $user): ?WorkingTime
{
$qb = $this->createQueryBuilder('w');
$qb->select('MAX(DATE(w.date))')
->where($qb->expr()->eq('w.user', ':user'))
->setParameter('user', $user->getId())
->andWhere($qb->expr()->isNotNull('w.approvedAt'))
;
$date = $qb->getQuery()->getSingleScalarResult();
if ($date === null) {
return null;
}
$qb = $this->createQueryBuilder('w');
$qb->select('w')
->where($qb->expr()->eq('w.user', ':user'))
->setParameter('user', $user->getId())
->andWhere($qb->expr()->eq('DATE(w.date)', 'DATE(:date)'))
->setParameter('date', $date)
;
return $qb->getQuery()->getOneOrNullResult();
}
public function getLatestApprovalDate(User $user): ?\DateTimeInterface
{
$qb = $this->createQueryBuilder('w');

View File

@@ -50,6 +50,10 @@ class SamlAuthenticator extends AbstractAuthenticator
return false;
}
if (!$request->isMethod(Request::METHOD_POST)) {
return false;
}
if (!$this->httpUtils->checkRequestPath($request, $this->options['check_path'])) {
return false;
}
@@ -81,7 +85,7 @@ class SamlAuthenticator extends AbstractAuthenticator
// file_put_contents(__DIR__ . '/../../var/log/saml.xml', $oneLoginAuth->getLastResponseXML());
if ($oneLoginAuth->getErrors()) {
if (\count($oneLoginAuth->getErrors()) > 0) {
throw new AuthenticationException($oneLoginAuth->getLastErrorReason());
}

View File

@@ -24,15 +24,12 @@ final class SamlAuthenticationSuccessHandler extends DefaultAuthenticationSucces
protected function determineTargetUrl(Request $request): string
{
if ($this->options['always_use_default_target_path']) {
return $this->options['default_target_path'];
}
$relayState = $request->get('RelayState');
$loginUrl = $this->httpUtils->generateUri($request, $this->options['login_path']);
if ($relayState !== null && $relayState !== '' && $relayState !== $loginUrl) {
return $relayState;
if (\is_scalar($relayState)) {
$relayState = (string) $relayState;
if ($relayState !== $this->httpUtils->generateUri($request, (string) $this->options['login_path'])) {
return $relayState;
}
}
return parent::determineTargetUrl($request);

View File

@@ -15,7 +15,7 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class TwoFactorCondition implements TwoFactorConditionInterface
{
public function __construct(private AuthorizationCheckerInterface $authorizationChecker)
public function __construct(private readonly AuthorizationCheckerInterface $authorizationChecker)
{
}

View File

@@ -88,13 +88,12 @@ final class DataTable implements \Countable, \IteratorAggregate
/**
* @param FormInterface<MultiUpdateTableDTO>|null $batchForm
* @return void
*/
public function setBatchForm(?FormInterface $batchForm): void
{
$this->batchForm = $batchForm;
if (!\array_key_exists('id', $this->columns)) {
if ($batchForm !== null && !\array_key_exists('id', $this->columns)) {
$this->addColumn('id', [
'class' => 'alwaysVisible multiCheckbox',
'orderBy' => false,

View File

@@ -32,6 +32,8 @@ final class WorkingTimeService
{
private const LATEST_APPROVAL_PREF = '_latest_approval';
private const LATEST_APPROVAL_FORMAT = 'Y-m-d H:i:s';
/** @var array<string, WorkingTime|null> */
private array $latestApprovals = [];
public function __construct(
private readonly TimesheetRepository $timesheetRepository,
@@ -58,6 +60,24 @@ final class WorkingTimeService
return $yearPerUserSummary;
}
/**
* @deprecated since 2.25.0 - kept for BC with old plugin versions
*/
public function getLatestApproval(User $user): ?WorkingTime
{
if ($user->getId() === null) {
return null;
}
$key = 'u_' . $user->getId();
if (!\array_key_exists($key, $this->latestApprovals)) {
$this->latestApprovals[$key] = $this->workingTimeRepository->getLatestApproval($user);
}
return $this->latestApprovals[$key];
}
public function getLatestApprovalDate(User $user): ?\DateTimeInterface
{
if ($user->getId() === null) {

View File

@@ -188,6 +188,14 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
]);
}
protected function assertBadRequestResponse(Response $response): void
{
$this->assertApiException($response, [
'code' => Response::HTTP_BAD_REQUEST,
'message' => 'Bad Request'
]);
}
protected function assertApiAccessDenied(HttpKernelBrowser $client, string $url, string $message = 'Forbidden'): void
{
$this->request($client, $url);

View File

@@ -1173,18 +1173,9 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertIsArray($result[0]);
self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]);
$query = ['tags' => ['Nothing-2-see', 'here']];
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query);
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertNotEmpty($result);
self::assertEquals(20, \count($result));
$this->assertIsArray($result[0]);
self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]);
$query = ['tags' => ['Nothing-2-see', 'not-existing-here']];
$this->request($client, '/api/timesheets', 'GET', $query);
$this->assertBadRequestResponse($client->getResponse());
}
public function testRestartAction(): void

View File

@@ -10,11 +10,13 @@
namespace App\Tests\Controller\Auth;
use App\Configuration\SamlConfiguration;
use App\Configuration\SamlConfigurationInterface;
use App\Configuration\SystemConfiguration;
use App\Controller\Auth\SamlController;
use App\Saml\SamlAuthFactory;
use App\Tests\Configuration\TestConfigLoader;
use App\Tests\Mocks\Saml\SamlAuthFactoryFactory;
use App\Tests\Mocks\SecurityFactory;
use App\Tests\Mocks\SystemConfigurationFactory;
use OneLogin\Saml2\Auth;
use PHPUnit\Framework\TestCase;
@@ -28,19 +30,14 @@ use Symfony\Component\Security\Http\SecurityRequestAttributes;
*/
class SamlControllerTest extends TestCase
{
/**
* @param array $settings
* @param array $loaderSettings
* @return SystemConfiguration
*/
protected function getSystemConfigurationMock(array $settings, array $loaderSettings = [])
protected function getSystemConfigurationMock(array $settings, array $loaderSettings = []): SystemConfiguration
{
$loader = new TestConfigLoader($loaderSettings);
return SystemConfigurationFactory::create($loader, $settings);
}
protected function getDefaultSettings(bool $activated = true)
protected function getDefaultSettings(bool $activated = true): array
{
return [
'saml' => [
@@ -66,10 +63,15 @@ class SamlControllerTest extends TestCase
$factory = $this->getMockBuilder(SamlAuthFactory::class)->disableOriginalConstructor()->getMock();
$sut = new SamlController($factory, $this->getSamlConfiguration());
$sut = $this->getSut($factory, $this->getSamlConfiguration());
$sut->assertionConsumerServiceAction();
}
public function getSut(SamlAuthFactory $authFactory, SamlConfigurationInterface $samlConfiguration): SamlController
{
return new SamlController($authFactory, $samlConfiguration, (new SecurityFactory($this))->create());
}
public function testMetadataAction(): void
{
$expectedXmlString = <<<EOD
@@ -101,7 +103,7 @@ class SamlControllerTest extends TestCase
$factory = $this->getMockBuilder(SamlAuthFactory::class)->disableOriginalConstructor()->getMock();
$factory->expects($this->once())->method('create')->willReturn($oauth);
$sut = new SamlController($factory, $this->getSamlConfiguration());
$sut = $this->getSut($factory, $this->getSamlConfiguration());
$result = $sut->metadataAction();
self::assertEquals('xml', $result->headers->get('Content-Type'));
@@ -129,7 +131,7 @@ class SamlControllerTest extends TestCase
$factory = $this->getMockBuilder(SamlAuthFactory::class)->disableOriginalConstructor()->getMock();
$sut = new SamlController($factory, $this->getSamlConfiguration());
$sut = $this->getSut($factory, $this->getSamlConfiguration());
$sut->loginAction($request);
}
@@ -140,7 +142,7 @@ class SamlControllerTest extends TestCase
$factory = $this->getMockBuilder(SamlAuthFactory::class)->disableOriginalConstructor()->getMock();
$sut = new SamlController($factory, $this->getSamlConfiguration(false));
$sut = $this->getSut($factory, $this->getSamlConfiguration(false));
$sut->loginAction(new Request());
}
@@ -151,7 +153,7 @@ class SamlControllerTest extends TestCase
$factory = $this->getMockBuilder(SamlAuthFactory::class)->disableOriginalConstructor()->getMock();
$sut = new SamlController($factory, $this->getSamlConfiguration(false));
$sut = $this->getSut($factory, $this->getSamlConfiguration(false));
$sut->metadataAction();
}
@@ -162,7 +164,7 @@ class SamlControllerTest extends TestCase
$factory = $this->getMockBuilder(SamlAuthFactory::class)->disableOriginalConstructor()->getMock();
$sut = new SamlController($factory, $this->getSamlConfiguration(false));
$sut = $this->getSut($factory, $this->getSamlConfiguration(false));
$sut->logoutAction();
}
@@ -173,7 +175,7 @@ class SamlControllerTest extends TestCase
$factory = $this->getMockBuilder(SamlAuthFactory::class)->disableOriginalConstructor()->getMock();
$sut = new SamlController($factory, $this->getSamlConfiguration(false));
$sut = $this->getSut($factory, $this->getSamlConfiguration(false));
$sut->assertionConsumerServiceAction();
}
}

View File

@@ -290,6 +290,7 @@ class ConfigurationTest extends TestCase
'break_warning_duration' => 0,
'long_running_duration' => 0,
'require_activity' => true,
'break_time_active' => false,
],
'duration_increment' => 15,
'time_increment' => 15,

View File

@@ -0,0 +1,31 @@
<?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\Mocks;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
class SecurityFactory extends AbstractMockFactory
{
public function create(): Security
{
$interface = $this->createMock(TokenInterface::class);
$interface->method('getUser')->willReturn(new User());
$storage = $this->createMock(TokenStorageInterface::class);
$storage->method('getToken')->willReturn($interface);
$container = $this->createMock(ContainerInterface::class);
$container->method('get')->willReturn($storage);
return new Security($container);
}
}

View File

@@ -15,6 +15,7 @@ use App\Saml\Security\SamlAuthenticationSuccessHandler;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\HttpUtils;
/**
@@ -22,19 +23,9 @@ use Symfony\Component\Security\Http\HttpUtils;
*/
class SamlAuthenticationSuccessHandlerTest extends TestCase
{
public function testWithAlwaysUseDefaultTargetPath(): void
{
$httpUtils = new HttpUtils($this->getUrlGenerator());
$handler = new SamlAuthenticationSuccessHandler($httpUtils, ['always_use_default_target_path' => true]);
$defaultTargetPath = $httpUtils->generateUri($this->getRequest('/sso/login'), $this->getOption($handler, 'default_target_path', '/'));
$response = $handler->onAuthenticationSuccess($this->getRequest('/login', 'http://localhost/relayed'), $this->getSamlToken());
$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertTrue($response->isRedirect($defaultTargetPath));
}
public function testRelayState(): void
{
$handler = new SamlAuthenticationSuccessHandler(new HttpUtils($this->getUrlGenerator()), ['always_use_default_target_path' => false]);
$handler = new SamlAuthenticationSuccessHandler(new HttpUtils($this->getUrlGenerator()));
$response = $handler->onAuthenticationSuccess($this->getRequest('/sso/login', 'http://localhost/relayed'), $this->getSamlToken());
$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertTrue($response->isRedirect('http://localhost/relayed'));
@@ -43,8 +34,8 @@ class SamlAuthenticationSuccessHandlerTest extends TestCase
public function testWithoutRelayState(): void
{
$httpUtils = new HttpUtils($this->getUrlGenerator());
$handler = new SamlAuthenticationSuccessHandler($httpUtils, ['always_use_default_target_path' => false]);
$defaultTargetPath = $httpUtils->generateUri($this->getRequest('/sso/login'), $this->getOption($handler, 'default_target_path', '/'));
$handler = new SamlAuthenticationSuccessHandler($httpUtils);
$defaultTargetPath = $httpUtils->generateUri($this->getRequest('/sso/login'), '/');
$response = $handler->onAuthenticationSuccess($this->getRequest(), $this->getSamlToken());
$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertTrue($response->isRedirect($defaultTargetPath));
@@ -53,16 +44,16 @@ class SamlAuthenticationSuccessHandlerTest extends TestCase
public function testRelayStateLoop(): void
{
$httpUtils = new HttpUtils($this->getUrlGenerator());
$handler = new SamlAuthenticationSuccessHandler($httpUtils, ['always_use_default_target_path' => false]);
$loginPath = $httpUtils->generateUri($this->getRequest('/sso/login'), $this->getOption($handler, 'login_path', '/login'));
$handler = new SamlAuthenticationSuccessHandler($httpUtils);
$loginPath = $httpUtils->generateUri($this->getRequest('/sso/login'), '/login');
$response = $handler->onAuthenticationSuccess($this->getRequest($loginPath), $this->getSamlToken());
$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertTrue(!$response->isRedirect($loginPath));
}
private function getUrlGenerator()
private function getUrlGenerator(): UrlGeneratorInterface
{
$urlGenerator = $this->getMockBuilder('Symfony\Component\Routing\Generator\UrlGeneratorInterface')->getMock();
$urlGenerator = $this->getMockBuilder(UrlGeneratorInterface::class)->getMock();
$urlGenerator
->expects($this->any())
->method('generate')
@@ -74,7 +65,7 @@ class SamlAuthenticationSuccessHandlerTest extends TestCase
return $urlGenerator;
}
private function getRequest($path = '/', $relayState = null)
private function getRequest(string $path = '/', ?string $relayState = null): Request
{
$params = [];
if (null !== $relayState) {
@@ -84,7 +75,7 @@ class SamlAuthenticationSuccessHandlerTest extends TestCase
return Request::create($path, 'get', $params);
}
private function getSamlToken()
private function getSamlToken(): SamlToken
{
$user = new User();
$user->setUserIdentifier('admin');
@@ -94,17 +85,4 @@ class SamlAuthenticationSuccessHandlerTest extends TestCase
return $token;
}
private function getOption($handler, $name, $default = null)
{
$reflection = new \ReflectionObject($handler);
$options = $reflection->getProperty('options');
$options->setAccessible(true);
$arr = $options->getValue($handler);
if (!\is_array($arr) || !isset($arr[$name])) {
return $default;
}
return $arr[$name];
}
}

View File

@@ -599,11 +599,6 @@ parameters:
count: 2
path: Controller/Auth/SamlControllerTest.php
-
message: "#^Method App\\\\Tests\\\\Controller\\\\Auth\\\\SamlControllerTest\\:\\:getDefaultSettings\\(\\) has no return type specified\\.$#"
count: 1
path: Controller/Auth/SamlControllerTest.php
-
message: "#^Method App\\\\Tests\\\\Controller\\\\Auth\\\\SamlControllerTest\\:\\:getSystemConfigurationMock\\(\\) has parameter \\$loaderSettings with no value type specified in iterable type array\\.$#"
count: 1
@@ -2364,51 +2359,6 @@ parameters:
count: 1
path: Saml/SamlProviderTest.php
-
message: "#^Method App\\\\Tests\\\\Saml\\\\Security\\\\SamlAuthenticationSuccessHandlerTest\\:\\:getOption\\(\\) has no return type specified\\.$#"
count: 1
path: Saml/Security/SamlAuthenticationSuccessHandlerTest.php
-
message: "#^Method App\\\\Tests\\\\Saml\\\\Security\\\\SamlAuthenticationSuccessHandlerTest\\:\\:getOption\\(\\) has parameter \\$default with no type specified\\.$#"
count: 1
path: Saml/Security/SamlAuthenticationSuccessHandlerTest.php
-
message: "#^Method App\\\\Tests\\\\Saml\\\\Security\\\\SamlAuthenticationSuccessHandlerTest\\:\\:getOption\\(\\) has parameter \\$handler with no type specified\\.$#"
count: 1
path: Saml/Security/SamlAuthenticationSuccessHandlerTest.php
-
message: "#^Method App\\\\Tests\\\\Saml\\\\Security\\\\SamlAuthenticationSuccessHandlerTest\\:\\:getOption\\(\\) has parameter \\$name with no type specified\\.$#"
count: 1
path: Saml/Security/SamlAuthenticationSuccessHandlerTest.php
-
message: "#^Method App\\\\Tests\\\\Saml\\\\Security\\\\SamlAuthenticationSuccessHandlerTest\\:\\:getRequest\\(\\) has no return type specified\\.$#"
count: 1
path: Saml/Security/SamlAuthenticationSuccessHandlerTest.php
-
message: "#^Method App\\\\Tests\\\\Saml\\\\Security\\\\SamlAuthenticationSuccessHandlerTest\\:\\:getRequest\\(\\) has parameter \\$path with no type specified\\.$#"
count: 1
path: Saml/Security/SamlAuthenticationSuccessHandlerTest.php
-
message: "#^Method App\\\\Tests\\\\Saml\\\\Security\\\\SamlAuthenticationSuccessHandlerTest\\:\\:getRequest\\(\\) has parameter \\$relayState with no type specified\\.$#"
count: 1
path: Saml/Security/SamlAuthenticationSuccessHandlerTest.php
-
message: "#^Method App\\\\Tests\\\\Saml\\\\Security\\\\SamlAuthenticationSuccessHandlerTest\\:\\:getSamlToken\\(\\) has no return type specified\\.$#"
count: 1
path: Saml/Security/SamlAuthenticationSuccessHandlerTest.php
-
message: "#^Method App\\\\Tests\\\\Saml\\\\Security\\\\SamlAuthenticationSuccessHandlerTest\\:\\:getUrlGenerator\\(\\) has no return type specified\\.$#"
count: 1
path: Saml/Security/SamlAuthenticationSuccessHandlerTest.php
-
message: "#^Method App\\\\Tests\\\\Timesheet\\\\Calculator\\\\BillableCalculatorTest\\:\\:getTestData\\(\\) has no return type specified\\.$#"
count: 1

View File

@@ -1930,6 +1930,14 @@
<source>import</source>
<target>Import</target>
</trans-unit>
<trans-unit id="EiWubBr" resname="sessions">
<source>Sessions</source>
<target>Sitzungen</target>
</trans-unit>
<trans-unit id="f0kKHXR" resname="sessions_logout">
<source>Log out of all devices</source>
<target>Von allen Geräten abmelden</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1930,6 +1930,14 @@
<source>import</source>
<target>Import</target>
</trans-unit>
<trans-unit id="EiWubBr" resname="sessions">
<source>Sessions</source>
<target>Sessions</target>
</trans-unit>
<trans-unit id="f0kKHXR" resname="sessions_logout">
<source>Log out of all devices</source>
<target>Log out of all devices</target>
</trans-unit>
</body>
</file>
</xliff>