[3.0] Update Symfony to 7.4 and dependencies, removed deprecated code, removed API password (#5622)

* deactivate deprecated auto-mapping
* use native month-picker with fallback on old browser
* bump dependencies to new major versions
* replace deprecated ARRAY column type with JSON
* named arguments
* nullable arguments
* native lazy ghosts
* update routes to use php config
* disable PHP < 8.4
* use static RTL configuration
* new qr code package
* clear out env file
* recipe updates
* fix deprecations
* preset new env variable if not existing
* bump maria db version examples
* remove deprecated api-token support
* fix validator deprecations
* remove timesheet category column
* fix broken validator autoconfiguration
* stabilize tests
* fix migration syntax compatibility
* fix doctrine deprecation
* fix datetime format
* remove constructor dependency
* only include visible preferences in invoice templates
* fix test container dependency
* removed ProjectConstraint multi-constraint logic
* allow to disable plugins in certain environments
* refactor TimesheetConstraint to non-service
* activate new interface methods
* prevent update issues with .env
* improved customer fixture
* removed $user->isExportDecimal()
This commit is contained in:
Kevin Papst
2026-01-04 15:04:25 +01:00
committed by GitHub
parent 9e87fc131b
commit 677f8d776c
467 changed files with 3929 additions and 6567 deletions

View File

@@ -214,7 +214,6 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
}
/**
* @param Response $response
* @param array<int, string>|array<string, mixed> $failedFields
* @param bool $extraFields test for the error "This form should not contain extra fields"
* @param array<int, string>|array<string, mixed> $globalError
@@ -231,7 +230,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
}
if (\count($globalError) > 0) {
self::assertArrayHasKey('errors', $result['errors']);
self::assertArrayHasKey('errors', $result['errors'], 'API response has no global errors, but expected ' . \count($globalError));
foreach ($globalError as $err) {
self::assertTrue(\in_array($err, $result['errors']['errors']), 'Missing global validation error: ' . $err); // @phpstan-ignore binaryOp.invalid
}
@@ -333,18 +332,13 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
// embedded meta data
case 'UserPreference':
return [
'name' => 'string',
'value' => '@string',
];
case 'CustomerMeta':
case 'ProjectMeta':
case 'ActivityMeta':
case 'TimesheetMeta':
return [
'name' => 'string',
'value' => 'string',
'value' => '@string',
];
// if a user is embedded in other objects
@@ -356,7 +350,6 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'username' => 'string',
'email' => 'string',
'enabled' => 'bool',
'apiToken' => 'bool',
'systemAccount' => 'bool',
'color' => 'string',
'avatar' => '@string',
@@ -376,7 +369,6 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'username' => 'string',
'email' => 'string',
'enabled' => 'bool',
'apiToken' => 'bool',
'systemAccount' => 'bool',
'color' => 'string',
'avatar' => '@string',

View File

@@ -50,10 +50,10 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
$expectedKeys = ['Actions', 'Activity', 'Default', 'Customer', 'Project', 'Tag', 'Team', 'Timesheet', 'User', 'Invoice', 'Export'];
$actual = array_keys($tags);
sort($actual);
sort($expectedKeys);
self::assertEquals($expectedKeys, $actual, \sprintf('Expected %s sections in API docs, but found %s.', \count($actual), \count($expectedKeys)));
//dd($expectedKeys, $actual);
foreach ($expectedKeys as $expectedKey) {
self::assertTrue(\in_array($expectedKey, $actual), 'Expected API section is missing: ' . $expectedKey);
}
$paths = [
'/api/actions/timesheet/{id}/{view}/{locale}',
@@ -115,7 +115,9 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
self::assertEquals('1.1', $json['info']['version']);
self::assertArrayHasKey('paths', $json);
self::assertEquals($paths, array_keys($json['paths']));
foreach ($paths as $path) {
self::assertArrayHasKey($path, $json['paths'], 'Missing API endpoint: ' . $path);
}
self::assertArrayHasKey('security', $json);
self::assertEquals(['bearer' => []], $json['security'][0]);

View File

@@ -1,168 +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\Tests\API\Authentication;
use App\API\Authentication\TokenAuthenticator;
use App\Entity\User;
use App\Repository\ApiUserRepository;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials;
#[CoversClass(TokenAuthenticator::class)]
#[Group('legacy')]
class TokenAuthenticatorTest extends TestCase
{
private function getSut(bool $verify = true): TokenAuthenticator
{
$userProvider = $this->createMock(ApiUserRepository::class);
$passwordHasherFactory = $this->createMock(PasswordHasherFactoryInterface::class);
$passwordHasher = $this->createMock(PasswordHasherInterface::class);
$passwordHasher->method('verify')->willReturn($verify);
$passwordHasherFactory->method('getPasswordHasher')->willReturn($passwordHasher);
return new TokenAuthenticator($userProvider, $passwordHasherFactory);
}
public function testSupports(): void
{
$sut = $this->getSut();
// not supporting because /api path is not the beginning of the URL
$request = new Request([], [], [], [], [], ['REQUEST_URI' => 'dfghj/api/doc/dfghj']);
self::assertFalse($sut->supports($request));
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo']);
self::assertFalse($sut->supports($request));
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/doc']);
self::assertFalse($sut->supports($request));
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo', 'HTTP_X-AUTH-TOKEN' => 'bar']);
self::assertTrue($sut->supports($request));
}
public function testAuthenticateWithMissingAuthHeader(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->expectExceptionMessage('Authentication required, missing user header: X-AUTH-USER');
$sut = $this->getSut();
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo']);
$sut->authenticate($request);
}
public function testAuthenticateWithMissingToken(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->expectExceptionMessage('Authentication required, missing token header: X-AUTH-TOKEN');
$sut = $this->getSut();
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo']);
$sut->authenticate($request);
}
public function testAuthenticateWithEmptyToken(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->expectExceptionMessage('Authentication required, missing token header: X-AUTH-TOKEN');
$sut = $this->getSut();
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo', 'HTTP_X-AUTH-TOKEN' => '']);
$sut->authenticate($request);
}
public function testAuthenticateWithMissingUser(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->expectExceptionMessage('Authentication required, missing user header: X-AUTH-USER');
$sut = $this->getSut();
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-TOKEN' => 'bar']);
$sut->authenticate($request);
}
public function testAuthenticateWithEmptyUser(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->expectExceptionMessage('Authentication required, missing user header: X-AUTH-USER');
$sut = $this->getSut();
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => '', 'HTTP_X-AUTH-TOKEN' => 'bar']);
$sut->authenticate($request);
}
public function testAuthenticate(): void
{
$sut = $this->getSut();
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo2', 'HTTP_X-AUTH-TOKEN' => 'bar']);
$passport = $sut->authenticate($request);
$badge = $passport->getBadge(UserBadge::class);
self::assertInstanceOf(UserBadge::class, $badge);
self::assertEquals('foo2', $badge->getUserIdentifier());
$user = new User();
$user->setApiToken('bar2');
$badge = $passport->getBadge(CustomCredentials::class);
self::assertInstanceOf(CustomCredentials::class, $badge);
self::assertFalse($badge->isResolved());
$badge->executeCustomChecker($user);
self::assertTrue($badge->isResolved());
}
public function testAuthenticateFailsOnMissingApiTokenForUser(): void
{
$this->expectException(BadCredentialsException::class);
$this->expectExceptionMessage('The user has no activated API account.');
$sut = $this->getSut();
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo2', 'HTTP_X-AUTH-TOKEN' => 'bar']);
$passport = $sut->authenticate($request);
$user = new User();
/** @var CustomCredentials $badge */
$badge = $passport->getBadge(CustomCredentials::class);
$badge->executeCustomChecker($user);
}
public function testAuthenticateFailsOnWrongPassword(): void
{
$this->expectException(BadCredentialsException::class);
$this->expectExceptionMessage('The presented password is invalid.');
$sut = $this->getSut(false);
$request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo2', 'HTTP_X-AUTH-TOKEN' => 'bar']);
$passport = $sut->authenticate($request);
$user = new User();
$user->setApiToken('bar');
/** @var CustomCredentials $badge */
$badge = $passport->getBadge(CustomCredentials::class);
$badge->executeCustomChecker($user);
}
}

View File

@@ -1,92 +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\Tests\API;
use App\DataFixtures\UserFixtures;
use App\Entity\User;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Component\HttpFoundation\Response;
/**
* Make sure that the deprecated API login with X-AUTH-USER and X-AUTH-TOKEN still works.
*/
#[Group('legacy')]
#[Group('integration')]
class AuthenticationTest extends APIControllerBaseTestCase
{
public function testPinIsSecure(): void
{
$this->assertUrlIsSecured('/api/ping');
}
public function testPingWithAccessToken(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/ping');
$response = $client->getResponse()->getContent();
self::assertIsString($response);
$result = json_decode($response, true);
self::assertIsArray($result);
self::assertEquals(['message' => 'pong'], $result);
}
public function testPingWithAuthTokenAndUsername(): void
{
$client = self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_USER, UserFixtures::DEFAULT_API_TOKEN));
$this->assertAccessIsGranted($client, '/api/ping');
$response = $client->getResponse()->getContent();
self::assertIsString($response);
$result = json_decode($response, true);
self::assertIsArray($result);
self::assertEquals(['message' => 'pong'], $result);
}
public function testPingWithInvalidAuthTokenAndUsername(): void
{
$client = self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_USER, 'xxxx'));
$url = '/api/ping';
$method = 'GET';
$this->request($client, $url, $method);
$response = $client->getResponse();
$data = [
'message' => 'Invalid credentials',
];
self::assertIsString($response->getContent());
self::assertEquals(
$data,
json_decode($response->getContent(), true),
\sprintf('The secure URL %s is not protected.', $url)
);
self::assertEquals(
Response::HTTP_FORBIDDEN,
$response->getStatusCode(),
\sprintf('The secure URL %s has the wrong status code %s.', $url, $response->getStatusCode())
);
}
/**
* @return array<string, string>
*/
private function getAuthHeader(string $username, string $password): array
{
return [
'HTTP_X_AUTH_USER' => $username,
'HTTP_X_AUTH_TOKEN' => $password,
];
}
}

View File

@@ -28,9 +28,9 @@ use Symfony\Component\HttpFoundation\Response;
#[Group('integration')]
class TimesheetControllerTest extends APIControllerBaseTestCase
{
public const DATE_FORMAT = 'Y-m-d H:i:s';
public const DATE_FORMAT_HTML5 = 'Y-m-d\TH:i:s';
public const TEST_TIMEZONE = 'Europe/London';
public const string DATE_FORMAT = 'Y-m-d H:i:s';
public const string DATE_FORMAT_HTML5 = 'Y-m-d\TH:i:s';
public const string TEST_TIMEZONE = 'Europe/London';
/**
* @return Timesheet[]
@@ -470,8 +470,8 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$data = [
'activity' => 1,
'project' => 1,
'begin' => ($dateTime->createDateTime('-8 hours'))->format('Y-m-d H:m:0'),
'end' => ($dateTime->createDateTime())->format('Y-m-d H:m:0'),
'begin' => ($dateTime->createDateTime('-8 hours'))->format('Y-m-d H:i:0'),
'end' => ($dateTime->createDateTime())->format('Y-m-d H:i:0'),
'description' => 'foo',
'fixedRate' => 2016,
'hourlyRate' => 127,
@@ -501,8 +501,8 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$data = [
'activity' => 1,
'project' => 1,
'begin' => ($dateTime->createDateTime('-8 hours'))->format('Y-m-d H:m:0'),
'end' => ($dateTime->createDateTime())->format('Y-m-d H:m:0'),
'begin' => ($dateTime->createDateTime('-8 hours'))->format('Y-m-d H:i:0'),
'end' => ($dateTime->createDateTime())->format('Y-m-d H:i:0'),
'description' => 'foo',
'fixedRate' => 2016,
'hourlyRate' => 127,
@@ -538,8 +538,8 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
'activity' => 1,
'project' => 1,
'user' => $user->getId(),
'begin' => ($dateTime->createDateTime('- 8 hours'))->format('Y-m-d H:m:0'),
'end' => ($dateTime->createDateTime())->format('Y-m-d H:m:0'),
'begin' => ($dateTime->createDateTime('- 8 hours'))->format('Y-m-d H:i:0'),
'end' => ($dateTime->createDateTime())->format('Y-m-d H:i:0'),
'description' => 'foo',
];
$json = json_encode($data);
@@ -585,8 +585,8 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$data = [
'activity' => $activity->getId(),
'project' => $project->getId(),
'begin' => (new \DateTime('- 8 hours'))->format('Y-m-d H:m:s'),
'end' => (new \DateTime())->format('Y-m-d H:m:s'),
'begin' => (new \DateTime('- 8 hours'))->format('Y-m-d H:i:s'),
'end' => (new \DateTime())->format('Y-m-d H:i:s'),
'description' => 'foo',
];
$json = json_encode($data);
@@ -612,8 +612,8 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$em->persist($project);
$data = [
'begin' => (new \DateTime('- 8 hours'))->format('Y-m-d H:m:s'),
'end' => (new \DateTime())->format('Y-m-d H:m:s'),
'begin' => (new \DateTime('- 8 hours'))->format('Y-m-d H:i:s'),
'end' => (new \DateTime())->format('Y-m-d H:i:s'),
'project' => $project->getId(),
'activity' => 99,
];
@@ -635,8 +635,8 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$em->flush();
$data = [
'begin' => (new \DateTime('- 8 hours'))->format('Y-m-d H:m:s'),
'end' => (new \DateTime())->format('Y-m-d H:m:s'),
'begin' => (new \DateTime('- 8 hours'))->format('Y-m-d H:i:s'),
'end' => (new \DateTime())->format('Y-m-d H:i:s'),
'project' => 99,
'activity' => $activity->getId(),
];
@@ -788,8 +788,8 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$data = [
'activity' => 1,
'project' => 1,
'begin' => ($dateTime->createDateTime('-8 hours'))->format('Y-m-d H:m:0'),
'end' => ($dateTime->createDateTime())->format('Y-m-d H:m:0'),
'begin' => ($dateTime->createDateTime('-8 hours'))->format('Y-m-d H:i:0'),
'end' => ($dateTime->createDateTime())->format('Y-m-d H:i:0'),
'description' => 'foo',
];
$json = json_encode($data);
@@ -812,8 +812,8 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$data = [
'activity' => 1,
'project' => 1,
'begin' => ($dateTime->createDateTime('- 7 hours'))->format('Y-m-d\TH:m:0'),
'end' => ($dateTime->createDateTime())->format('Y-m-d\TH:m:0'),
'begin' => ($dateTime->createDateTime('- 7 hours'))->format('Y-m-d\TH:i:0'),
'end' => ($dateTime->createDateTime())->format('Y-m-d\TH:i:0'),
'description' => 'foo',
'billable' => false,
];
@@ -853,8 +853,8 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$data = [
'activity' => 1,
'project' => 1,
'begin' => (new \DateTime('- 7 hours'))->format('Y-m-d\TH:m:s'),
'end' => (new \DateTime())->format('Y-m-d\TH:m:s'),
'begin' => (new \DateTime('- 7 hours'))->format('Y-m-d\TH:i:s'),
'end' => (new \DateTime())->format('Y-m-d\TH:i:s'),
'description' => 'foo',
'exported' => true,
];
@@ -874,12 +874,14 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$timesheets = $this->importFixtureForUser(User::ROLE_USER);
$begin = new \DateTimeImmutable();
$end = $begin->modify('-1 day');
$data = [
'activity' => 10,
'project' => 1,
'begin' => (new \DateTime())->format('Y-m-d H:m'),
'end' => (new \DateTime('-1 day'))->format('Y-m-d H:m'),
'begin' => $begin->format('Y-m-d H:m'),
'end' => $end->format('Y-m-d H:m'),
'description' => 'foo',
];
$json = json_encode($data);
@@ -1034,6 +1036,7 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertEquals(3, \count($result));
foreach ($result as $timesheet) {
self::assertIsArray($timesheet);
@@ -1284,7 +1287,9 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TimesheetEntity', $result);
self::assertEquals('foo', $result['description']);
self::assertEquals([['name' => 'sdfsdf', 'value' => 'nnnnn'], ['name' => '1234567890', 'value' => '1234567890']], $result['metaFields']);
self::assertIsArray($result['metaFields']);
self::assertEquals(['name' => 'sdfsdf', 'value' => 'nnnnn'], $result['metaFields'][0]);
self::assertEquals(['name' => '1234567890', 'value' => '1234567890'], $result['metaFields'][1]);
self::assertEquals(['another', 'testing', 'bar'], $result['tags']);
$em = $this->getEntityManager();
@@ -1332,8 +1337,8 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$data = [
'activity' => 1,
'project' => 1,
'begin' => ($dateTime->createDateTime('- 8 hours'))->format('Y-m-d H:m:0'),
'end' => ($dateTime->createDateTime())->format('Y-m-d H:m:0'),
'begin' => ($dateTime->createDateTime('- 8 hours'))->format('Y-m-d H:i:0'),
'end' => ($dateTime->createDateTime())->format('Y-m-d H:i:0'),
'description' => 'foo',
'fixedRate' => 2016,
'hourlyRate' => 127
@@ -1488,7 +1493,16 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TimesheetEntity', $result);
self::assertEquals(['name' => 'metatestmock', 'value' => 'another,testing,bar'], $result['metaFields'][0]);
self::assertIsArray($result['metaFields']);
$found = false;
foreach ($result['metaFields'] as $field) {
self::assertIsArray($field);
if ($field['name'] === 'metatestmock') {
self::assertEquals(['name' => 'metatestmock', 'value' => 'another,testing,bar'], $field);
$found = true;
}
}
self::assertTrue($found);
$em = $this->getEntityManager();
/** @var Timesheet $timesheet */

View File

@@ -327,7 +327,6 @@ class UserControllerTest extends APIControllerBaseTestCase
'username' => '1', // not existing in form
'email' => '',
'plainPassword' => '123456', // not existing in form
'plainApiToken' => '123456', // not existing in form
'language' => 'xx',
'timezone' => 'XXX/YYY',
'roles' => [