Release 2.59 (#5957)

This commit is contained in:
Kevin Papst
2026-06-05 19:05:10 +02:00
committed by GitHub
parent 79d0a2102b
commit 87c85270a9
77 changed files with 2571 additions and 1697 deletions

View File

@@ -153,6 +153,21 @@ class ActivityServiceTest extends TestCase
self::assertEquals((string) $expected, $activity->getNumber());
}
public function testActivityNumberIncrementsForMultipleCreateCallsOnSameInstance(): void
{
$sut = $this->getSut(null, null, null, ['number_format' => '{ac,1}']);
$activity1 = $sut->createNewActivity();
$activity2 = $sut->createNewActivity();
$activity3 = $sut->createNewActivity();
// countActivity() is mocked and returns 0, the formatter normalizes increaseBy=0 to 1,
// so the first generated number is 2. The in-instance counter must bump subsequent calls.
self::assertEquals('2', $activity1->getNumber());
self::assertEquals('3', $activity2->getNumber());
self::assertEquals('4', $activity3->getNumber());
}
/**
* @return array<int, array<int, string|\DateTime|int>>
*/

View File

@@ -39,6 +39,27 @@ class WizardControllerTest extends AbstractControllerBaseTestCase
$this->assertAccessIsGranted($client, '/wizard/profile');
}
public function testPasswordWizard(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/wizard/password');
}
public function testFinishWizard(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
// mark all wizards as seen so the WizardSubscriber does not interfere
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
$user->setWizardAsSeen('intro');
$user->setWizardAsSeen('profile');
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
$this->assertAccessIsGranted($client, '/wizard/finish');
}
public function testWizardDoesNotAppearOnFirstLoginIfDisabled(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
@@ -72,12 +93,13 @@ class WizardControllerTest extends AbstractControllerBaseTestCase
$this->assertIsRedirect($client, '/wizard/intro');
}
public function testProfileWizardSubmitRedirectsToDoneAndMarksSeen(): void
public function testProfileWizardSubmitMarksSeenAndRedirectsToNext(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
$user->setPreferenceValue('__wizards__', null);
$user->setWizardAsSeen('intro');
$user->setRequiresPasswordReset(false);
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
@@ -92,7 +114,11 @@ class WizardControllerTest extends AbstractControllerBaseTestCase
$values['form'][UserPreference::SKIN] = 'auto';
$client->submit($form, $values);
$this->assertIsRedirect($client, '/wizard/done');
// After a successful profile submit, the controller always redirects to
// the virtual /wizard/next/ route — the WizardManager decides where
// that ultimately lands. The route name and the _locale query string
// make for a stable assertion target.
$this->assertIsRedirect($client, '/wizard/next/', false);
$this->getEntityManager()->clear();
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
@@ -112,7 +138,7 @@ class WizardControllerTest extends AbstractControllerBaseTestCase
$this->assertIsRedirect($client, '/wizard/profile');
}
public function testProfileWizardSubmitRedirectsToPasswordIfResetRequired(): void
public function testPasswordWizardSubmitClearsResetFlagAndRedirectsToNext(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
@@ -121,19 +147,80 @@ class WizardControllerTest extends AbstractControllerBaseTestCase
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
$crawler = $this->request($client, '/wizard/profile');
$form = $crawler->filter('form[name=form]')->form();
$crawler = $this->request($client, '/wizard/password');
$form = $crawler->filter('form[name=user_password]')->form();
$values = $form->getPhpValues();
$values['form']['reload'] = '0';
$values['user_password']['plainPassword']['first'] = 'new-pa$$word-123';
$values['user_password']['plainPassword']['second'] = 'new-pa$$word-123';
$client->submit($form, $values);
$this->assertIsRedirect($client, '/wizard/password');
$this->assertIsRedirect($client, '/wizard/next/', false);
$this->getEntityManager()->clear();
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
self::assertFalse($user->requiresPasswordReset());
}
public function testDoneWizard(): void
public function testNextRedirectsToFirstUnseenStep(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/wizard/done');
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
$user->setPreferenceValue('__wizards__', null);
$user->setRequiresPasswordReset(false);
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
$this->request($client, '/wizard/next/');
// The WizardSubscriber intercepts /wizard/next/ on kernel.request and
// redirects to the first unseen step (intro, since we just cleared it).
$this->assertIsRedirect($client, '/wizard/intro');
}
public function testNextRedirectsToFinishWhenAllStepsSeen(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
$user->setWizardAsSeen('intro');
$user->setWizardAsSeen('profile');
$user->setRequiresPasswordReset(false);
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
$this->request($client, '/wizard/next/');
// With nothing left to see the subscriber returns early and the
// controller falls back to the finish page.
$this->assertIsRedirect($client, '/wizard/finish');
}
public function testPreviousRedirectsToPreviousStep(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->request($client, '/wizard/previous/profile');
$this->assertIsRedirect($client, '/wizard/intro');
}
public function testPreviousFallsBackToIntroWhenNoPreviousStep(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
// intro is the very first step, so there is nothing before it
$this->request($client, '/wizard/previous/intro');
$this->assertIsRedirect($client, '/wizard/intro');
}
public function testPreviousFallsBackToIntroForUnknownStep(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->request($client, '/wizard/previous/does-not-exist');
$this->assertIsRedirect($client, '/wizard/intro');
}
}

View File

@@ -169,6 +169,34 @@ class CustomerServiceTest extends TestCase
self::assertEquals($expected($date), $customer->getNumber());
}
public function testCustomerNumberIncrementsForMultipleCreateCallsOnSameInstance(): void
{
$configuration = SystemConfigurationFactory::createStub([
'defaults' => [
'customer' => [
'timezone' => 'Europe/Vienna',
'country' => 'IN',
'currency' => 'RUB',
]
],
'customer' => [
'number_format' => '{cc,1}',
]
]);
$sut = $this->getSut(null, null, $configuration);
$customer1 = $sut->createNewCustomer('A');
$customer2 = $sut->createNewCustomer('B');
$customer3 = $sut->createNewCustomer('C');
// countCustomer() is mocked and returns 0, the formatter normalizes increaseBy=0 to 1,
// so the first generated number is 2. The in-instance counter must bump subsequent calls.
self::assertEquals('2', $customer1->getNumber());
self::assertEquals('3', $customer2->getNumber());
self::assertEquals('4', $customer3->getNumber());
}
/**
* @return array<int, array{0: string, 1: \Closure(\DateTimeInterface): string}>
*/

View File

@@ -777,6 +777,16 @@ class UserTest extends TestCase
self::assertTrue($user->isPasswordRequestNonExpired(7200));
}
public function testSignatureDate(): void
{
$user = new User();
self::assertEquals('', $user->getSignatureDate());
$user->resetSecuritySignature();
// shortest possible result: 2026-05-31T01:18:19Z
self::assertGreaterThanOrEqual(20, \strlen($user->getSignatureDate()));
}
private static function userWithId(int $id): User
{
$user = new User();

View File

@@ -0,0 +1,162 @@
<?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\Event;
use App\Entity\User;
use App\Event\WizardEvent;
use App\Wizard\WizardStep;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(WizardEvent::class)]
class WizardEventTest extends TestCase
{
public function testGetUserReturnsConstructorArgument(): void
{
$user = new User();
$sut = new WizardEvent($user);
self::assertSame($user, $sut->getUser());
}
public function testInitiallyEmpty(): void
{
$sut = new WizardEvent(new User());
self::assertSame([], $sut->getSteps());
self::assertSame([], $sut->getWizards());
self::assertFalse($sut->hasStep('intro'));
self::assertNull($sut->getStep('intro'));
}
public function testAddStepAndAccessors(): void
{
$sut = new WizardEvent(new User());
$step = new WizardStep('intro', 'wizard_intro', 100);
$sut->addStep($step);
self::assertTrue($sut->hasStep('intro'));
self::assertSame($step, $sut->getStep('intro'));
self::assertSame([$step], $sut->getSteps());
}
public function testAddStepReplacesStepWithSameId(): void
{
$sut = new WizardEvent(new User());
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
$replacement = new WizardStep('intro', 'wizard_intro_v2', 100);
$sut->addStep($replacement);
self::assertSame($replacement, $sut->getStep('intro'));
self::assertCount(1, $sut->getSteps());
}
public function testGetStepsIsSortedByOrderAscending(): void
{
$sut = new WizardEvent(new User());
$intro = new WizardStep('intro', 'wizard_intro', 100);
$profile = new WizardStep('profile', 'wizard_profile', 200);
$plugin = new WizardStep('plugin', 'plugin_step', 150);
// intentionally added out of order
$sut->addStep($profile);
$sut->addStep($intro);
$sut->addStep($plugin);
self::assertSame([$intro, $plugin, $profile], $sut->getSteps());
}
public function testRemoveStep(): void
{
$sut = new WizardEvent(new User());
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
$sut->addStep(new WizardStep('profile', 'wizard_profile', 200));
$sut->removeStep('intro');
self::assertFalse($sut->hasStep('intro'));
self::assertTrue($sut->hasStep('profile'));
self::assertCount(1, $sut->getSteps());
}
public function testRemoveUnknownStepIsNoop(): void
{
$sut = new WizardEvent(new User());
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
$sut->removeStep('does-not-exist');
self::assertCount(1, $sut->getSteps());
}
public function testAddWizardCreatesStepsInInsertionOrder(): void
{
$sut = new WizardEvent(new User());
$sut->addWizard('intro', 'wizard_intro');
$sut->addWizard('profile', 'wizard_profile');
$steps = $sut->getSteps();
self::assertCount(2, $steps);
self::assertSame('intro', $steps[0]->id);
self::assertSame('wizard_intro', $steps[0]->route);
self::assertSame('profile', $steps[1]->id);
self::assertSame('wizard_profile', $steps[1]->route);
// addWizard must assign ascending order values so insertion order is preserved
self::assertLessThan($steps[1]->order, $steps[0]->order);
}
public function testGetWizardsReturnsIdToRouteMapInOrder(): void
{
$sut = new WizardEvent(new User());
$sut->addStep(new WizardStep('profile', 'wizard_profile', 200));
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
self::assertSame(
['intro' => 'wizard_intro', 'profile' => 'wizard_profile'],
$sut->getWizards()
);
}
public function testGetNextWizardReturnsRouteOfFollowingStep(): void
{
$sut = new WizardEvent(new User());
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
$sut->addStep(new WizardStep('profile', 'wizard_profile', 200));
self::assertSame('wizard_profile', $sut->getNextWizard('intro'));
}
public function testGetNextWizardReturnsFinishForLastStep(): void
{
$sut = new WizardEvent(new User());
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
$sut->addStep(new WizardStep('profile', 'wizard_profile', 200));
self::assertSame('wizard_finish', $sut->getNextWizard('profile'));
}
public function testGetNextWizardReturnsFinishForUnknownStep(): void
{
$sut = new WizardEvent(new User());
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
self::assertSame('wizard_finish', $sut->getNextWizard('does-not-exist'));
}
public function testGetNextWizardOnEmptyEventReturnsFinish(): void
{
$sut = new WizardEvent(new User());
self::assertSame('wizard_finish', $sut->getNextWizard('anything'));
}
}

View File

@@ -179,7 +179,7 @@ class PasswordResetSubscriberTest extends TestCase
$urlGenerator
->expects($this->once())
->method('generate')
->with('wizard', ['wizard' => 'password'])
->with('wizard_password')
->willReturn('/wizard/password');
$security = $this->createMock(AuthorizationCheckerInterface::class);

View File

@@ -10,11 +10,15 @@
namespace App\Tests\EventSubscriber;
use App\Entity\User;
use App\Event\WizardEvent;
use App\EventSubscriber\WizardSubscriber;
use App\Tests\Mocks\SystemConfigurationFactory;
use App\Wizard\WizardManager;
use App\Wizard\WizardStep;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
@@ -41,7 +45,7 @@ class WizardSubscriberTest extends TestCase
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->never())->method('getToken');
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub(), $this->createWizardManager());
$event = $this->createRequestEvent('/dashboard', false);
$sut->onKernelRequest($event);
@@ -58,7 +62,7 @@ class WizardSubscriberTest extends TestCase
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn(null);
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub(), $this->createWizardManager());
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
@@ -89,7 +93,7 @@ class WizardSubscriberTest extends TestCase
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub(), $this->createWizardManager());
$event = $this->createRequestEvent($uri);
$sut->onKernelRequest($event);
@@ -113,7 +117,7 @@ class WizardSubscriberTest extends TestCase
'user' => [
'wizard' => true,
]
]));
]), $this->createWizardManager());
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
@@ -137,7 +141,7 @@ class WizardSubscriberTest extends TestCase
'user' => [
'wizard' => true,
]
]));
]), $this->createWizardManager());
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
@@ -163,7 +167,7 @@ class WizardSubscriberTest extends TestCase
'user' => [
'wizard' => false,
]
]));
]), $this->createWizardManager());
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
@@ -181,7 +185,7 @@ class WizardSubscriberTest extends TestCase
$urlGenerator
->expects($this->once())
->method('generate')
->with('wizard', ['wizard' => 'profile'])
->with('wizard_profile')
->willReturn('/wizard/profile');
$security = $this->createMock(AuthorizationCheckerInterface::class);
@@ -190,11 +194,16 @@ class WizardSubscriberTest extends TestCase
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$manager = $this->createWizardManager(static function (WizardEvent $event): void {
$event->addStep(new WizardStep('intro', 'wizard_intro', 100));
$event->addStep(new WizardStep('profile', 'wizard_profile', 200));
});
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
'user' => [
'wizard' => true,
]
]));
]), $manager);
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
@@ -204,6 +213,39 @@ class WizardSubscriberTest extends TestCase
self::assertSame('/wizard/profile', $response->headers->get('Location'));
}
public function testOnKernelRequestDoesNotRedirectWhenAllStepsSeen(): void
{
$user = new User();
$user->setWizardAsSeen('intro');
$user->setWizardAsSeen('profile');
$token = $this->createUserToken($user);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->never())->method('generate');
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(true);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$manager = $this->createWizardManager(static function (WizardEvent $event): void {
$event->addStep(new WizardStep('intro', 'wizard_intro', 100));
$event->addStep(new WizardStep('profile', 'wizard_profile', 200));
});
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
'user' => [
'wizard' => true,
]
]), $manager);
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
private function createUserToken(User $user): TokenInterface
{
$token = $this->createMock(TokenInterface::class);
@@ -219,4 +261,21 @@ class WizardSubscriberTest extends TestCase
return new RequestEvent($kernel, $request, $mainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
}
/**
* @param (callable(WizardEvent): void)|null $stepRegistrar
*/
private function createWizardManager(?callable $stepRegistrar = null): WizardManager
{
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$dispatcher->method('dispatch')->willReturnCallback(static function (object $event) use ($stepRegistrar): object {
if ($stepRegistrar !== null && $event instanceof WizardEvent) {
$stepRegistrar($event);
}
return $event;
});
return new WizardManager($dispatcher);
}
}

View File

@@ -85,6 +85,7 @@ class InvoiceItemDefaultHydratorTest extends TestCase
'entry.user_alias',
'entry.user_display',
'entry.user_title',
'entry.user_account',
'entry.user_preference.foo',
'entry.user_preference.mad',
'entry.activity',

View File

@@ -37,6 +37,7 @@ class InvoiceModelUserHydratorTest extends TestCase
'user.email',
'user.name',
'user.title',
'user.account',
'user.meta.hello',
'user.meta.kitty',
];

View File

@@ -205,6 +205,7 @@ class DebugRendererTest extends TestCase
'activity.budget_open_plain',
'activity.time_budget_open',
'activity.time_budget_open_plain',
'user.account',
'user.alias',
'user.display',
'user.email',
@@ -341,6 +342,7 @@ class DebugRendererTest extends TestCase
'entry.user_display',
'entry.user_alias',
'entry.user_title',
'entry.user_account',
'entry.user_preference.foo',
'entry.user_preference.mad',
'entry.activity',

View File

@@ -13,6 +13,7 @@ use App\Pdf\SafeRemoteContentClient;
use Mpdf\PsrHttpMessageShim\Request;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
@@ -27,7 +28,7 @@ class SafeRemoteContentClientTest extends TestCase
new MockResponse('image-bytes', ['http_code' => 200])
);
$sut = new SafeRemoteContentClient($client);
$sut = new SafeRemoteContentClient($client, $this->createStub(LoggerInterface::class));
$response = $sut->sendRequest(new Request('GET', 'https://example.com/logo.png'));
self::assertSame(200, $response->getStatusCode());
@@ -40,7 +41,7 @@ class SafeRemoteContentClientTest extends TestCase
new MockResponse('not found', ['http_code' => 404])
);
$sut = new SafeRemoteContentClient($client);
$sut = new SafeRemoteContentClient($client, $this->createStub(LoggerInterface::class));
$response = $sut->sendRequest(new Request('GET', 'https://example.com/missing.png'));
self::assertSame(404, $response->getStatusCode());
@@ -55,7 +56,10 @@ class SafeRemoteContentClientTest extends TestCase
throw new TransportException('IP blocked');
});
$sut = new SafeRemoteContentClient($client);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects(self::once())->method('error');
$sut = new SafeRemoteContentClient($client, $logger);
$response = $sut->sendRequest(new Request('GET', 'http://127.0.0.1/internal'));
self::assertSame(502, $response->getStatusCode());
@@ -68,7 +72,10 @@ class SafeRemoteContentClientTest extends TestCase
$inner = new MockHttpClient(new MockResponse('should-not-be-reached'));
$safe = new NoPrivateNetworkHttpClient($inner);
$sut = new SafeRemoteContentClient($safe);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects(self::once())->method('error');
$sut = new SafeRemoteContentClient($safe, $logger);
$response = $sut->sendRequest(new Request('GET', 'http://127.0.0.1/internal'));
self::assertSame(502, $response->getStatusCode());

View File

@@ -193,6 +193,65 @@ class ProjectServiceTest extends TestCase
self::assertEquals($expected($date), $project->getNumber());
}
public function testProjectNumberIncrementsForMultipleCreateCallsOnSameInstance(): void
{
$configuration = SystemConfigurationFactory::createStub([
'project' => [
'copy_teams_on_create' => false,
'number_format' => '{pc,1}',
]
]);
$sut = $this->getSut(null, null, $configuration);
$project1 = $sut->createNewProject();
$project2 = $sut->createNewProject();
$project3 = $sut->createNewProject();
// countProject() is mocked and returns 0, the formatter normalizes increaseBy=0 to 1,
// so the first generated number is 2. Without the in-instance counter all three
// calls would re-use "2" — which is exactly the bug reported by the importer.
self::assertEquals('2', $project1->getNumber());
self::assertEquals('3', $project2->getNumber());
self::assertEquals('4', $project3->getNumber());
}
public function testProjectNumberSkipsAlreadyExistingNumbers(): void
{
$configuration = SystemConfigurationFactory::createStub([
'project' => [
'copy_teams_on_create' => false,
'number_format' => '{pc,1}',
]
]);
$repository = $this->createMock(ProjectRepository::class);
$repository->method('countProject')->willReturn(0);
// Pretend the database already contains projects with numbers 2 and 3 — the
// service must skip them and only return the next unused number.
$repository->method('findOneBy')->willReturnCallback(function (array $criteria): ?Project {
if (\in_array($criteria['number'] ?? null, ['2', '3'], true)) {
return new Project();
}
return null;
});
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$dispatcher->method('dispatch')->willReturnCallback(static fn ($event) => $event);
$validator = $this->createMock(ValidatorInterface::class);
$validator->method('validate')->willReturn(new ConstraintViolationList());
$sut = new ProjectService($repository, $configuration, $dispatcher, $validator);
$project1 = $sut->createNewProject();
$project2 = $sut->createNewProject();
self::assertEquals('4', $project1->getNumber());
self::assertEquals('5', $project2->getNumber());
}
/**
* @return array<int, array{0: string, 1: \Closure(\DateTimeInterface): string}>
*/

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\Security;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Http\AccessMapInterface;
/**
* Regression test for the 2FA-API-bypass security advisory.
*
* The firewall-level access_control for ^/api was raised from IS_AUTHENTICATED
* to IS_AUTHENTICATED_REMEMBERED so that a TwoFactorToken (which only satisfies
* IS_AUTHENTICATED) can no longer reach any /api/* route, while remember_me
* sessions used by the web frontend continue to work.
*/
#[Group('integration')]
class ApiAccessControlTest extends KernelTestCase
{
public function testApiRouteRequiresAuthenticatedRemembered(): void
{
self::bootKernel();
$accessMap = self::getContainer()->get('security.access_map');
self::assertInstanceOf(AccessMapInterface::class, $accessMap);
[$attributes] = $accessMap->getPatterns(Request::create('/api/users/me'));
self::assertIsArray($attributes);
self::assertContains(
'IS_AUTHENTICATED_REMEMBERED',
$attributes,
'API access_control must require IS_AUTHENTICATED_REMEMBERED to keep a TwoFactorToken from reaching /api/*'
);
self::assertNotContains(
'IS_AUTHENTICATED',
$attributes,
'API access_control must not fall back to IS_AUTHENTICATED, which a TwoFactorToken satisfies'
);
}
}

View File

@@ -0,0 +1,139 @@
<?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\Voter;
use App\Entity\User;
use App\Voter\ApiVoter;
use PHPUnit\Framework\Attributes\CoversClass;
use Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorToken;
use Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorTokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
#[CoversClass(ApiVoter::class)]
class ApiVoterTest extends AbstractVoterTestCase
{
private function createApiVoter(bool $twoFactorInProgress = false, array $rolePermissions = []): ApiVoter
{
$checker = $this->createMock(AuthorizationCheckerInterface::class);
$checker->method('isGranted')->willReturnCallback(
static fn (string $attribute): bool => $attribute === 'IS_AUTHENTICATED_2FA_IN_PROGRESS' && $twoFactorInProgress
);
$permissionManager = $rolePermissions === []
? $this->getRolePermissionManager()
: $this->getRolePermissionManager($rolePermissions, true);
return new ApiVoter($permissionManager, $checker);
}
/**
* Regression test for the 2FA-bypass security advisory: the session cookie
* issued after the password step (which still carries a TwoFactorToken) must
* not grant access to any #[IsGranted('API')] endpoint.
*/
public function testTwoFactorTokenIsDenied(): void
{
$user = self::getUser(1, User::ROLE_USER);
$inner = new UsernamePasswordToken($user, 'secured_area', $user->getRoles());
$token = new TwoFactorToken($inner, null, 'secured_area', ['totp']);
self::assertInstanceOf(TwoFactorTokenInterface::class, $token);
self::assertSame(
VoterInterface::ACCESS_DENIED,
$this->createApiVoter()->vote($token, null, ['API'])
);
}
/**
* Defense-in-depth branch: even if a future Scheb release stopped using a
* TwoFactorTokenInterface, the IS_AUTHENTICATED_2FA_IN_PROGRESS role check
* must still deny.
*/
public function testTwoFactorInProgressFromAuthCheckerIsDenied(): void
{
$user = self::getUser(1, User::ROLE_USER);
$token = new UsernamePasswordToken($user, 'secured_area', $user->getRoles());
self::assertSame(
VoterInterface::ACCESS_DENIED,
$this->createApiVoter(twoFactorInProgress: true)->vote($token, null, ['API'])
);
}
/**
* Regression test for the security.yaml change to IS_AUTHENTICATED_REMEMBERED:
* a remember_me-backed session (the frontend uses the API this way because
* always_remember_me is enabled) must still pass the voter.
*/
public function testRememberMeSessionIsGranted(): void
{
$user = self::getUser(1, User::ROLE_USER);
$token = new RememberMeToken($user, 'secured_area', 'secret');
self::assertNotInstanceOf(TwoFactorTokenInterface::class, $token); // @phpstan-ignore staticMethod.alreadyNarrowedType
self::assertSame(
VoterInterface::ACCESS_GRANTED,
$this->createApiVoter()->vote($token, null, ['API'])
);
}
public function testRegularSessionIsGranted(): void
{
$user = self::getUser(1, User::ROLE_USER);
$token = new UsernamePasswordToken($user, 'secured_area', $user->getRoles());
self::assertSame(
VoterInterface::ACCESS_GRANTED,
$this->createApiVoter()->vote($token, null, ['API'])
);
}
public function testApiTokenWithoutPermissionIsDenied(): void
{
$user = self::getUser(1, User::ROLE_USER);
$token = new UsernamePasswordToken($user, 'api', $user->getRoles());
$token->setAttribute('api-token', true);
// ROLE_USER does not carry 'api_access' in the default permission map
self::assertSame(
VoterInterface::ACCESS_DENIED,
$this->createApiVoter()->vote($token, null, ['API'])
);
}
public function testApiTokenWithPermissionIsGranted(): void
{
$user = self::getUser(1, User::ROLE_USER);
$token = new UsernamePasswordToken($user, 'api', $user->getRoles());
$token->setAttribute('api-token', true);
$voter = $this->createApiVoter(rolePermissions: ['ROLE_USER' => ['api_access']]);
self::assertSame(
VoterInterface::ACCESS_GRANTED,
$voter->vote($token, null, ['API'])
);
}
public function testNonUserSubjectIsDenied(): void
{
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn(null);
self::assertSame(
VoterInterface::ACCESS_DENIED,
$this->createApiVoter()->vote($token, null, ['API'])
);
}
}

View File

@@ -0,0 +1,198 @@
<?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\Wizard;
use App\Entity\User;
use App\Event\WizardEvent;
use App\Wizard\WizardManager;
use App\Wizard\WizardStep;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Psr\EventDispatcher\EventDispatcherInterface;
#[CoversClass(WizardManager::class)]
class WizardManagerTest extends TestCase
{
public function testGetStepsIncludesBuiltInIntroAndProfile(): void
{
$sut = $this->createManager();
$steps = $sut->getSteps(new User());
self::assertCount(2, $steps);
self::assertSame('intro', $steps[0]->id);
self::assertSame('wizard_intro', $steps[0]->route);
self::assertSame('profile', $steps[1]->id);
self::assertSame('wizard_profile', $steps[1]->route);
}
public function testGetStepsIncludesPluginStepsInSortedOrder(): void
{
$sut = $this->createManager(static function (WizardEvent $event): void {
// plugin step inserted between intro (100) and profile (200)
$event->addStep(new WizardStep('plugin', 'plugin_route', 150));
});
$ids = array_map(static fn (WizardStep $s): string => $s->id, $sut->getSteps(new User()));
self::assertSame(['intro', 'plugin', 'profile'], $ids);
}
public function testGetStepsDispatchesEventOncePerCall(): void
{
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$dispatcher->expects($this->exactly(2))
->method('dispatch')
->willReturnArgument(0);
$sut = new WizardManager($dispatcher);
$sut->getSteps(new User());
$sut->getSteps(new User());
}
public function testGetFirstUnseenStepReturnsFirstStepInitially(): void
{
$sut = $this->createManager();
$step = $sut->getFirstUnseenStep(new User());
self::assertNotNull($step);
self::assertSame('intro', $step->id);
}
public function testGetFirstUnseenStepSkipsSeenSteps(): void
{
$sut = $this->createManager();
$user = new User();
$user->setWizardAsSeen('intro');
$step = $sut->getFirstUnseenStep($user);
self::assertNotNull($step);
self::assertSame('profile', $step->id);
}
public function testGetFirstUnseenStepReturnsNullWhenAllSeen(): void
{
$sut = $this->createManager();
$user = new User();
$user->setWizardAsSeen('intro');
$user->setWizardAsSeen('profile');
self::assertNull($sut->getFirstUnseenStep($user));
}
public function testGetNextStepReturnsFollowingStep(): void
{
$sut = $this->createManager();
$step = $sut->getNextStep(new User(), 'intro');
self::assertNotNull($step);
self::assertSame('profile', $step->id);
}
public function testGetNextStepReturnsNullForLastStep(): void
{
$sut = $this->createManager();
self::assertNull($sut->getNextStep(new User(), 'profile'));
}
public function testGetNextStepReturnsNullForUnknownStep(): void
{
$sut = $this->createManager();
self::assertNull($sut->getNextStep(new User(), 'does-not-exist'));
}
public function testGetPreviousStepReturnsPrecedingStep(): void
{
$sut = $this->createManager();
$step = $sut->getPreviousStep(new User(), 'profile');
self::assertNotNull($step);
self::assertSame('intro', $step->id);
}
public function testGetPreviousStepReturnsNullForFirstStep(): void
{
$sut = $this->createManager();
self::assertNull($sut->getPreviousStep(new User(), 'intro'));
}
public function testGetPreviousStepReturnsNullForUnknownStep(): void
{
$sut = $this->createManager();
self::assertNull($sut->getPreviousStep(new User(), 'does-not-exist'));
}
public function testGetNavigationReturnsPreviousAndNextRouteNames(): void
{
$sut = $this->createManager();
self::assertSame(
['previous' => 'wizard_intro', 'next' => 'wizard_finish'],
$sut->getNavigation(new User(), 'profile')
);
}
public function testGetNavigationFallsBackToWizardFinishForLastStep(): void
{
$sut = $this->createManager();
$nav = $sut->getNavigation(new User(), 'profile');
self::assertSame('wizard_finish', $nav['next']);
}
public function testGetNavigationReturnsNullPreviousForFirstStep(): void
{
$sut = $this->createManager();
$nav = $sut->getNavigation(new User(), 'intro');
self::assertNull($nav['previous']);
self::assertSame('wizard_profile', $nav['next']);
}
public function testGetNavigationResolvesPluginNeighbours(): void
{
$sut = $this->createManager(static function (WizardEvent $event): void {
// inserts itself between intro (100) and profile (200)
$event->addStep(new WizardStep('plugin', 'plugin_route', 150));
});
self::assertSame(
['previous' => 'wizard_intro', 'next' => 'wizard_profile'],
$sut->getNavigation(new User(), 'plugin')
);
}
/**
* @param (callable(WizardEvent): void)|null $stepRegistrar
*/
private function createManager(?callable $stepRegistrar = null): WizardManager
{
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$dispatcher->method('dispatch')->willReturnCallback(static function (object $event) use ($stepRegistrar): object {
if ($stepRegistrar !== null && $event instanceof WizardEvent) {
$stepRegistrar($event);
}
return $event;
});
return new WizardManager($dispatcher);
}
}

View File

@@ -0,0 +1,34 @@
<?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\Wizard;
use App\Wizard\WizardStep;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(WizardStep::class)]
class WizardStepTest extends TestCase
{
public function testConstructorAssignsAllProperties(): void
{
$step = new WizardStep('intro', 'wizard_intro', 100);
self::assertSame('intro', $step->id);
self::assertSame('wizard_intro', $step->route);
self::assertSame(100, $step->order);
}
public function testOrderDefaultsToZero(): void
{
$step = new WizardStep('intro', 'wizard_intro');
self::assertSame(0, $step->order);
}
}