Release 2.57 (#5929)

This commit is contained in:
Kevin Papst
2026-05-21 22:14:10 +02:00
committed by GitHub
parent f0ce1c7bd6
commit 976d38e8a4
101 changed files with 4226 additions and 1119 deletions

View File

@@ -325,6 +325,15 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'metaFields' => ['result' => 'array', 'type' => 'InvoiceMeta'],
];
case 'Comment':
return [
'id' => 'int',
'message' => 'string',
'createdBy' => ['result' => 'object', 'type' => '@User'],
'createdAt' => '@datetime',
'pinned' => 'bool',
];
case 'PageActionItem':
return [
'id' => 'string',

View File

@@ -73,6 +73,9 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
'/api/customers/{id}/meta',
'/api/customers/{id}/rates',
'/api/customers/{id}/rates/{rateId}',
'/api/customers/{id}/comments',
'/api/customers/{id}/comments/{comment}/pin',
'/api/customers/{id}/comments/{comment}',
'/api/export/{id}',
'/api/invoices',
'/api/invoices/{id}',
@@ -83,6 +86,9 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
'/api/projects/{id}/meta',
'/api/projects/{id}/rates',
'/api/projects/{id}/rates/{rateId}',
'/api/projects/{id}/comments',
'/api/projects/{id}/comments/{comment}/pin',
'/api/projects/{id}/comments/{comment}',
'/api/ping',
'/api/version',
'/api/plugins',

View File

@@ -12,6 +12,7 @@ namespace App\Tests\API;
use App\DataFixtures\UserFixtures;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\CustomerComment;
use App\Entity\CustomerMeta;
use App\Entity\CustomerRate;
use App\Entity\Project;
@@ -578,4 +579,299 @@ class CustomerControllerTest extends APIControllerBaseTestCase
'message' => 'Not Found'
]);
}
// ------------------------------- [COMMENTS] -------------------------------
private function createComment(string $message = 'A customer comment', bool $pinned = false, int $customerId = 1): CustomerComment
{
/** @var CustomerRepository $repository */
$repository = $this->getEntityManager()->getRepository(Customer::class);
/** @var Customer|null $customer */
$customer = $repository->find($customerId);
self::assertInstanceOf(Customer::class, $customer);
$comment = new CustomerComment($customer);
$comment->setMessage($message);
$comment->setPinned($pinned);
$comment->setCreatedBy($this->getUserByRole(User::ROLE_ADMIN));
$repository->saveComment($comment);
return $comment;
}
public function testGetCommentsIsSecure(): void
{
$this->assertUrlIsSecured('/api/customers/1/comments');
}
public function testGetCommentsIsSecureForRole(): void
{
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/customers/1/comments');
}
public function testGetCommentsActionWithUnknownCustomer(): void
{
$this->assertEntityNotFound(User::ROLE_ADMIN, '/api/customers/' . PHP_INT_MAX . '/comments');
}
public function testGetCommentsAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$comment = $this->createComment('Visible comment', true);
$this->request($client, '/api/customers/1/comments');
self::assertTrue(
$client->getResponse()->isSuccessful(),
$client->getResponse()->getStatusCode() . ' ' . (string) $client->getResponse()->getContent()
);
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertCount(1, $result);
self::assertIsArray($result[0]);
self::assertApiResponseTypeStructure('Comment', $result[0]);
$first = $result[0];
self::assertSame($comment->getId(), $first['id']);
self::assertSame('Visible comment', $first['message']);
self::assertTrue($first['pinned']);
self::assertIsArray($first['createdBy']);
self::assertSame($this->getAuthenticatedUserId(User::ROLE_ADMIN), $first['createdBy']['id']);
self::assertSame(UserFixtures::USERNAME_ADMIN, $first['createdBy']['username']);
self::assertIsString($first['createdAt']);
}
public function testPostCommentIsSecure(): void
{
$this->assertUrlIsSecured('/api/customers/1/comments', Request::METHOD_POST);
}
public function testPostCommentIsSecureForRole(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$json = json_encode(['message' => 'Denied']);
self::assertIsString($json);
$this->request($client, '/api/customers/1/comments', Request::METHOD_POST, [], $json);
$this->assertApiResponseAccessDenied($client->getResponse());
}
public function testPostCommentActionWithUnknownCustomer(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertEntityNotFoundForPost($client, '/api/customers/' . PHP_INT_MAX . '/comments', ['message' => 'Missing customer']);
}
public function testPostCommentActionWithInvalidData(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$data = [
'unexpected' => 'field',
];
$json = json_encode($data);
self::assertIsString($json);
$this->request($client, '/api/customers/1/comments', Request::METHOD_POST, [], $json);
$response = $client->getResponse();
self::assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
$this->assertApiCallValidationError($response, ['message'], true);
}
public function testPostCommentAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$data = [
'message' => 'Created from API',
'pinned' => true,
];
$json = json_encode($data);
self::assertIsString($json);
$this->request($client, '/api/customers/1/comments', 'POST', [], $json);
self::assertTrue(
$client->getResponse()->isSuccessful(),
$client->getResponse()->getStatusCode() . ' ' . (string) $client->getResponse()->getContent()
);
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertIsArray($result['createdBy']);
self::assertIsInt($result['id']);
self::assertNotEmpty($result['id']);
self::assertSame('Created from API', $result['message']);
self::assertTrue($result['pinned']);
self::assertSame($this->getAuthenticatedUserId(User::ROLE_ADMIN), $result['createdBy']['id']);
/** @var CustomerComment|null $comment */
$comment = $this->getEntityManager()->getRepository(CustomerComment::class)->find($result['id']);
self::assertInstanceOf(CustomerComment::class, $comment);
self::assertSame('Created from API', $comment->getMessage());
self::assertTrue($comment->isPinned());
}
public function testToggleCommentPinIsSecure(): void
{
$comment = $this->createComment('Secured pin');
self::assertNotNull($comment->getId());
self::ensureKernelShutdown();
$client = self::createClient();
$this->request($client, '/api/customers/1/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
$this->assertApiException($client->getResponse(), [
'code' => Response::HTTP_UNAUTHORIZED,
'message' => 'Unauthorized'
]);
}
public function testToggleCommentPinIsSecureForRole(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$comment = $this->createComment('Cannot pin');
self::assertNotNull($comment->getId());
$this->request($client, '/api/customers/1/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
$this->assertApiResponseAccessDenied($client->getResponse());
}
public function testToggleCommentPinActionWithUnknownCustomer(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$comment = $this->createComment('Pin me');
self::assertNotNull($comment->getId());
$this->request($client, '/api/customers/' . PHP_INT_MAX . '/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
$this->assertApiException($client->getResponse(), [
'code' => Response::HTTP_NOT_FOUND,
'message' => 'Not Found'
]);
}
public function testToggleCommentPinActionWithUnknownComment(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/api/customers/1/comments/' . PHP_INT_MAX . '/pin', Request::METHOD_PATCH);
$this->assertApiException($client->getResponse(), [
'code' => Response::HTTP_NOT_FOUND,
'message' => 'Not Found'
]);
}
public function testToggleCommentPinActionDeniesForeignComment(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
[, $customer] = $this->loadCustomerData();
$customerId = $customer->getId();
self::assertNotNull($customerId);
$comment = $this->createComment('Foreign comment', false, $customerId);
self::assertNotNull($comment->getId());
$this->request($client, '/api/customers/1/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
$this->assertApiResponseAccessDenied($client->getResponse());
}
public function testToggleCommentPinAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$comment = $this->createComment('Toggle me');
self::assertNotNull($comment->getId());
$this->request($client, '/api/customers/1/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
self::assertTrue(
$client->getResponse()->isSuccessful(),
$client->getResponse()->getStatusCode() . ' ' . (string) $client->getResponse()->getContent()
);
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertSame($comment->getId(), $result['id']);
self::assertSame('Toggle me', $result['message']);
self::assertTrue($result['pinned']);
/** @var CustomerComment|null $updated */
$updated = $this->getEntityManager()->getRepository(CustomerComment::class)->find($comment->getId());
self::assertInstanceOf(CustomerComment::class, $updated);
self::assertTrue($updated->isPinned());
}
public function testDeleteCommentIsSecure(): void
{
$comment = $this->createComment('Secured delete');
self::assertNotNull($comment->getId());
self::ensureKernelShutdown();
$client = self::createClient();
$this->request($client, '/api/customers/1/comments/' . $comment->getId(), Request::METHOD_DELETE);
$this->assertApiException($client->getResponse(), [
'code' => Response::HTTP_UNAUTHORIZED,
'message' => 'Unauthorized'
]);
}
public function testDeleteCommentIsSecureForRole(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$comment = $this->createComment('Cannot delete');
self::assertNotNull($comment->getId());
$this->request($client, '/api/customers/1/comments/' . $comment->getId(), Request::METHOD_DELETE);
$this->assertApiResponseAccessDenied($client->getResponse());
}
public function testDeleteCommentActionWithUnknownCustomer(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$comment = $this->createComment('Delete me later');
self::assertNotNull($comment->getId());
$this->assertNotFoundForDelete($client, '/api/customers/' . PHP_INT_MAX . '/comments/' . $comment->getId());
}
public function testDeleteCommentActionWithUnknownComment(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertNotFoundForDelete($client, '/api/customers/1/comments/' . PHP_INT_MAX);
}
public function testDeleteCommentActionDeniesForeignComment(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
[, $customer] = $this->loadCustomerData();
$customerId = $customer->getId();
self::assertNotNull($customerId);
$comment = $this->createComment('Foreign comment', false, $customerId);
self::assertNotNull($comment->getId());
$this->request($client, '/api/customers/1/comments/' . $comment->getId(), Request::METHOD_DELETE);
$this->assertApiResponseAccessDenied($client->getResponse());
}
public function testDeleteCommentAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$comment = $this->createComment('Delete me');
self::assertNotNull($comment->getId());
$commentId = $comment->getId();
$this->request($client, '/api/customers/1/comments/' . $commentId, Request::METHOD_DELETE);
self::assertTrue($client->getResponse()->isSuccessful());
self::assertSame(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode());
self::assertEmpty($client->getResponse()->getContent());
self::assertNull($this->getEntityManager()->getRepository(CustomerComment::class)->find($commentId));
}
}

View File

@@ -12,6 +12,7 @@ namespace App\Tests\API;
use App\DataFixtures\UserFixtures;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\ProjectComment;
use App\Entity\ProjectMeta;
use App\Entity\ProjectRate;
use App\Entity\RateInterface;
@@ -687,4 +688,299 @@ class ProjectControllerTest extends APIControllerBaseTestCase
'message' => 'Not Found'
]);
}
// ------------------------------- [COMMENTS] -------------------------------
private function createComment(string $message = 'A project comment', bool $pinned = false, int $projectId = 1): ProjectComment
{
/** @var ProjectRepository $repository */
$repository = $this->getEntityManager()->getRepository(Project::class);
/** @var Project|null $project */
$project = $repository->find($projectId);
self::assertInstanceOf(Project::class, $project);
$comment = new ProjectComment($project);
$comment->setMessage($message);
$comment->setPinned($pinned);
$comment->setCreatedBy($this->getUserByRole(User::ROLE_ADMIN));
$repository->saveComment($comment);
return $comment;
}
public function testGetCommentsIsSecure(): void
{
$this->assertUrlIsSecured('/api/projects/1/comments');
}
public function testGetCommentsIsSecureForRole(): void
{
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/projects/1/comments');
}
public function testGetCommentsActionWithUnknownProject(): void
{
$this->assertEntityNotFound(User::ROLE_ADMIN, '/api/projects/' . PHP_INT_MAX . '/comments');
}
public function testGetCommentsAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$comment = $this->createComment('Visible comment', true);
$this->request($client, '/api/projects/1/comments');
self::assertTrue(
$client->getResponse()->isSuccessful(),
$client->getResponse()->getStatusCode() . ' ' . (string) $client->getResponse()->getContent()
);
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertCount(1, $result);
self::assertIsArray($result[0]);
self::assertApiResponseTypeStructure('Comment', $result[0]);
$first = $result[0];
self::assertSame($comment->getId(), $first['id']);
self::assertSame('Visible comment', $first['message']);
self::assertTrue($first['pinned']);
self::assertIsArray($first['createdBy']);
self::assertSame($this->getAuthenticatedUserId(User::ROLE_ADMIN), $first['createdBy']['id']);
self::assertSame(UserFixtures::USERNAME_ADMIN, $first['createdBy']['username']);
self::assertIsString($first['createdAt']);
}
public function testPostCommentIsSecure(): void
{
$this->assertUrlIsSecured('/api/projects/1/comments', Request::METHOD_POST);
}
public function testPostCommentIsSecureForRole(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$json = json_encode(['message' => 'Denied']);
self::assertIsString($json);
$this->request($client, '/api/projects/1/comments', Request::METHOD_POST, [], $json);
$this->assertApiResponseAccessDenied($client->getResponse());
}
public function testPostCommentActionWithUnknownProject(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertEntityNotFoundForPost($client, '/api/projects/' . PHP_INT_MAX . '/comments', ['message' => 'Missing project']);
}
public function testPostCommentActionWithInvalidData(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$data = [
'unexpected' => 'field',
];
$json = json_encode($data);
self::assertIsString($json);
$this->request($client, '/api/projects/1/comments', Request::METHOD_POST, [], $json);
$response = $client->getResponse();
self::assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
$this->assertApiCallValidationError($response, ['message'], true);
}
public function testPostCommentAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$data = [
'message' => 'Created from API',
'pinned' => true,
];
$json = json_encode($data);
self::assertIsString($json);
$this->request($client, '/api/projects/1/comments', 'POST', [], $json);
self::assertTrue(
$client->getResponse()->isSuccessful(),
$client->getResponse()->getStatusCode() . ' ' . (string) $client->getResponse()->getContent()
);
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertIsArray($result['createdBy']);
self::assertIsInt($result['id']);
self::assertNotEmpty($result['id']);
self::assertSame('Created from API', $result['message']);
self::assertTrue($result['pinned']);
self::assertSame($this->getAuthenticatedUserId(User::ROLE_ADMIN), $result['createdBy']['id']);
/** @var ProjectComment|null $comment */
$comment = $this->getEntityManager()->getRepository(ProjectComment::class)->find($result['id']);
self::assertInstanceOf(ProjectComment::class, $comment);
self::assertSame('Created from API', $comment->getMessage());
self::assertTrue($comment->isPinned());
}
public function testToggleCommentPinIsSecure(): void
{
$comment = $this->createComment('Secured pin');
self::assertNotNull($comment->getId());
self::ensureKernelShutdown();
$client = self::createClient();
$this->request($client, '/api/projects/1/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
$this->assertApiException($client->getResponse(), [
'code' => Response::HTTP_UNAUTHORIZED,
'message' => 'Unauthorized'
]);
}
public function testToggleCommentPinIsSecureForRole(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$comment = $this->createComment('Cannot pin');
self::assertNotNull($comment->getId());
$this->request($client, '/api/projects/1/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
$this->assertApiResponseAccessDenied($client->getResponse());
}
public function testToggleCommentPinActionWithUnknownProject(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$comment = $this->createComment('Pin me');
self::assertNotNull($comment->getId());
$this->request($client, '/api/projects/' . PHP_INT_MAX . '/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
$this->assertApiException($client->getResponse(), [
'code' => Response::HTTP_NOT_FOUND,
'message' => 'Not Found'
]);
}
public function testToggleCommentPinActionWithUnknownComment(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/api/projects/1/comments/' . PHP_INT_MAX . '/pin', Request::METHOD_PATCH);
$this->assertApiException($client->getResponse(), [
'code' => Response::HTTP_NOT_FOUND,
'message' => 'Not Found'
]);
}
public function testToggleCommentPinActionDeniesForeignComment(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
[, $project] = $this->loadProjectTestData();
$projectId = $project->getId();
self::assertNotNull($projectId);
$comment = $this->createComment('Foreign comment', false, $projectId);
self::assertNotNull($comment->getId());
$this->request($client, '/api/projects/1/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
$this->assertApiResponseAccessDenied($client->getResponse());
}
public function testToggleCommentPinAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$comment = $this->createComment('Toggle me');
self::assertNotNull($comment->getId());
$this->request($client, '/api/projects/1/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
self::assertTrue(
$client->getResponse()->isSuccessful(),
$client->getResponse()->getStatusCode() . ' ' . (string) $client->getResponse()->getContent()
);
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertSame($comment->getId(), $result['id']);
self::assertSame('Toggle me', $result['message']);
self::assertTrue($result['pinned']);
/** @var ProjectComment|null $updated */
$updated = $this->getEntityManager()->getRepository(ProjectComment::class)->find($comment->getId());
self::assertInstanceOf(ProjectComment::class, $updated);
self::assertTrue($updated->isPinned());
}
public function testDeleteCommentIsSecure(): void
{
$comment = $this->createComment('Secured delete');
self::assertNotNull($comment->getId());
self::ensureKernelShutdown();
$client = self::createClient();
$this->request($client, '/api/projects/1/comments/' . $comment->getId(), Request::METHOD_DELETE);
$this->assertApiException($client->getResponse(), [
'code' => Response::HTTP_UNAUTHORIZED,
'message' => 'Unauthorized'
]);
}
public function testDeleteCommentIsSecureForRole(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$comment = $this->createComment('Cannot delete');
self::assertNotNull($comment->getId());
$this->request($client, '/api/projects/1/comments/' . $comment->getId(), Request::METHOD_DELETE);
$this->assertApiResponseAccessDenied($client->getResponse());
}
public function testDeleteCommentActionWithUnknownProject(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$comment = $this->createComment('Delete me later');
self::assertNotNull($comment->getId());
$this->assertNotFoundForDelete($client, '/api/projects/' . PHP_INT_MAX . '/comments/' . $comment->getId());
}
public function testDeleteCommentActionWithUnknownComment(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertNotFoundForDelete($client, '/api/projects/1/comments/' . PHP_INT_MAX);
}
public function testDeleteCommentActionDeniesForeignComment(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
[, $project] = $this->loadProjectTestData();
$projectId = $project->getId();
self::assertNotNull($projectId);
$comment = $this->createComment('Foreign comment', false, $projectId);
self::assertNotNull($comment->getId());
$this->request($client, '/api/projects/1/comments/' . $comment->getId(), Request::METHOD_DELETE);
$this->assertApiResponseAccessDenied($client->getResponse());
}
public function testDeleteCommentAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$comment = $this->createComment('Delete me');
self::assertNotNull($comment->getId());
$commentId = $comment->getId();
$this->request($client, '/api/projects/1/comments/' . $commentId, Request::METHOD_DELETE);
self::assertTrue($client->getResponse()->isSuccessful());
self::assertSame(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode());
self::assertEmpty($client->getResponse()->getContent());
self::assertNull($this->getEntityManager()->getRepository(ProjectComment::class)->find($commentId));
}
}

View File

@@ -14,6 +14,7 @@ use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Tag;
use App\Entity\Team;
use App\Entity\Timesheet;
use App\Entity\TimesheetMeta;
use App\Entity\User;
@@ -24,6 +25,7 @@ use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelBrowser;
#[Group('integration')]
class TimesheetControllerTest extends APIControllerBaseTestCase
@@ -131,6 +133,68 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]);
}
public function testGetCollectionForOtherUserDeniedWhenTeamleadIsOnlyPlainMemberOfOwnerTeam(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$owner = $this->getUserByRole(User::ROLE_USER);
$teamlead = $this->getUserByRole(User::ROLE_TEAMLEAD);
$sharedTeam = new Team('timesheet-list-shared');
$sharedTeam->addUser($owner);
$sharedTeam->addUser($teamlead);
$em->persist($sharedTeam);
$timesheet = $this->persistRestrictedTimesheet($owner, [], running: false);
$ownerId = $owner->getId();
self::assertIsInt($ownerId);
self::assertNotNull($timesheet->getId());
$this->request($client, '/api/timesheets', 'GET', ['user' => (string) $ownerId]);
$this->assertApiResponseAccessDenied($client->getResponse());
$this->request($client, '/api/timesheets', 'GET', ['users' => [(string) $ownerId]]);
$this->assertApiResponseAccessDenied($client->getResponse());
}
public function testGetCollectionForOtherUserAllowedWhenTeamleadOfOwnerTeam(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$owner = $this->getUserByRole(User::ROLE_USER);
$teamlead = $this->getUserByRole(User::ROLE_TEAMLEAD);
$sharedTeam = new Team('timesheet-list-teamlead');
$sharedTeam->addUser($owner);
$sharedTeam->addTeamlead($teamlead);
$em->persist($sharedTeam);
$timesheet = $this->persistRestrictedTimesheet($owner, [], running: false);
$ownerId = $owner->getId();
self::assertIsInt($ownerId);
self::assertNotNull($timesheet->getId());
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', ['user' => (string) $ownerId]);
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertCount(1, $result);
self::assertIsArray($result[0]);
self::assertSame($ownerId, $result[0]['user']);
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', ['users' => [(string) $ownerId]]);
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertCount(1, $result);
self::assertIsArray($result[0]);
self::assertSame($ownerId, $result[0]['user']);
}
public function testGetCollectionForAllUserIsSecure(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
@@ -226,7 +290,7 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$factory = DateTimeFactory::createByUser($user);
$begin = $factory->createDateTime('first day of this month');
$begin = $begin->setTime(0, 0, 1);
$begin = $begin->setTime(0, 0, 0);
$end = $factory->createDateTime('last day of this month');
$end = $end->setTime(23, 59, 59);
@@ -283,7 +347,7 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$factory = DateTimeFactory::createByUser($user);
$begin = $factory->create('first day of this month');
$begin = $begin->setTime(0, 0, 1);
$begin = $begin->setTime(0, 0, 0);
$end = $factory->create('last day of this month');
$end = $end->setTime(23, 59, 59);
@@ -325,12 +389,12 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$fixture = new TimesheetFixtures($user, 7);
$fixture->setExported(true);
$fixture->setStartDate(new \DateTime('first day of this month'));
$fixture->setStartDate($factory->createDateTime('first day of this month 00:00:01'));
$fixture->setAllowEmptyDescriptions(false);
$this->importFixture($fixture);
$begin = $factory->create('first day of this month');
$begin = $begin->setTime(0, 0, 1);
$begin = $begin->setTime(0, 0, 0);
$end = $factory->create('last day of this month');
$end = $end->setTime(23, 59, 59);
@@ -664,6 +728,39 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$this->assertApiCallValidationError($client->getResponse(), ['project']);
}
public function testPostActionRejectsTeamRestrictedVisibleProjectOutsideUsersScope(): void
{
$dateTime = new DateTimeFactory(new \DateTimeZone(self::TEST_TIMEZONE));
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$owner = $this->getUserByRole(User::ROLE_USER);
[$restrictedProject] = $this->createTeamRestrictedProjectFixture('post');
$globalActivity = $this->getEntityManager()->getRepository(Activity::class)->find(1);
self::assertInstanceOf(Activity::class, $globalActivity);
self::assertNull($globalActivity->getProject(), 'Sanity check: fixture activity 1 must stay global for the POST PoC.');
$this->assertProjectIsHiddenFromApi($client, $restrictedProject);
$data = [
'activity' => $globalActivity->getId(),
'project' => $restrictedProject->getId(),
'begin' => ($dateTime->createDateTime('-8 hours'))->format(self::DATE_FORMAT),
'end' => ($dateTime->createDateTime())->format(self::DATE_FORMAT),
'description' => 'GHSA-vrr2-create-attempt',
];
$json = json_encode($data);
self::assertIsString($json);
$this->request($client, '/api/timesheets', 'POST', [], $json);
$this->assertApiCallValidationError($client->getResponse(), ['project' => 'The selected choice is invalid.']);
self::assertSame(
0,
$this->getEntityManager()->getRepository(Timesheet::class)->count([
'user' => $owner,
'description' => 'GHSA-vrr2-create-attempt',
])
);
}
// check for activity, as this is a required field. It will not be included in the select, as it is
// already filtered within the repository due to the hidden flag
public function testPostActionWithInvisibleActivity(): void
@@ -852,6 +949,46 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
self::assertFalse($result['billable']);
}
public function testPatchActionRejectsReassigningOwnTimesheetToTeamRestrictedVisibleProject(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$owner = $this->getUserByRole(User::ROLE_USER);
$em = $this->getEntityManager();
$allowedProject = $em->getRepository(Project::class)->find(1);
self::assertInstanceOf(Project::class, $allowedProject);
$globalActivity = $em->getRepository(Activity::class)->find(1);
self::assertInstanceOf(Activity::class, $globalActivity);
self::assertNull($globalActivity->getProject(), 'Sanity check: fixture activity 1 must stay global for the PATCH PoC.');
$timesheet = $this->persistFinishedTimesheet($owner, $allowedProject, $globalActivity, 'GHSA-vrr2-patch-baseline');
[$restrictedProject] = $this->createTeamRestrictedProjectFixture('patch');
$this->assertProjectIsHiddenFromApi($client, $restrictedProject);
$json = json_encode(['project' => $restrictedProject->getId()]);
self::assertIsString($json);
$this->request($client, '/api/timesheets/' . $timesheet->getId(), 'PATCH', [], $json);
$this->assertApiCallValidationError($client->getResponse(), ['project' => 'The selected choice is invalid.']);
$em->clear();
$reloaded = $em->getRepository(Timesheet::class)->find($timesheet->getId());
self::assertInstanceOf(Timesheet::class, $reloaded);
self::assertSame($allowedProject->getId(), $reloaded->getProject()?->getId());
$this->request($client, '/api/timesheets/' . $timesheet->getId(), 'GET', ['full' => 'true']);
self::assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TimesheetEntity', $result);
self::assertSame($allowedProject->getId(), $result['project']);
self::assertNotSame($restrictedProject->getId(), $result['project']);
}
public function testPatchActionWithInvalidUser(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
@@ -1513,4 +1650,453 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$timesheet = $em->getRepository(Timesheet::class)->find($id);
self::assertEquals('another,testing,bar', $timesheet->getMetaField('metatestmock')->getValue());
}
// ------------------------------------------------------------------
// CVE-2024-29200 / GHSA-cj3c-5xpm-cx94 — per-record IDOR regression suite.
//
// The list endpoint fix (TimesheetRepository::addPermissionCriteria) covers
// GET /api/timesheets only. The per-record routes load a Timesheet by id
// and rely entirely on TimesheetVoter for authorisation. Previously
// the voter only asked "is this the caller's own entry?" — so a teamlead
// with view_other_timesheet could read, mutate or delete any timesheet by
// id, regardless of team scope. These tests pin the new team-scoped
// behaviour (RolePermissionManager::checkTeamAccessTimesheet) on every
// affected route.
// ------------------------------------------------------------------
public function testCveIdorTeamleadCannotReachAnyPerRecordRouteWhenCustomerTeamRestricts(): void
{
// Direct reproduction of the security advisory's PoC. The customer
// belongs to a team the teamlead is not in; the timesheet owner is
// a different user; the teamlead must be denied on every per-record
// route, not just on GET /api/timesheets.
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$owner = $this->getUserByRole(User::ROLE_USER);
$ownerTeam = new Team('owner team');
$ownerTeam->addUser($owner);
$em->persist($ownerTeam);
$customerTeam = new Team('customer team');
$customerTeam->addUser($owner);
$em->persist($customerTeam);
$timesheet = $this->persistRestrictedTimesheet($owner, [$customerTeam], running: false);
$id = $timesheet->getId();
self::assertIsInt($id);
// 1) GET /api/timesheets/{id}
$this->assertApiAccessDenied($client, '/api/timesheets/' . $id);
// 2) PATCH /api/timesheets/{id}
$patch = json_encode(['description' => 'HIJACKED_BY_BOB']);
self::assertIsString($patch);
$this->request($client, '/api/timesheets/' . $id, 'PATCH', [], $patch);
$this->assertApiResponseAccessDenied($client->getResponse());
// 3) PATCH /api/timesheets/{id}/stop and 4) GET .../stop
$this->request($client, '/api/timesheets/' . $id . '/stop', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse());
$this->request($client, '/api/timesheets/' . $id . '/stop', 'GET');
$this->assertApiResponseAccessDenied($client->getResponse());
// 5) PATCH /api/timesheets/{id}/restart and 6) GET .../restart
$this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse());
$this->request($client, '/api/timesheets/' . $id . '/restart', 'GET');
$this->assertApiResponseAccessDenied($client->getResponse());
// 7) PATCH /api/timesheets/{id}/duplicate
$this->request($client, '/api/timesheets/' . $id . '/duplicate', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse());
// 8) PATCH /api/timesheets/{id}/export
$this->request($client, '/api/timesheets/' . $id . '/export', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse());
// 9) PATCH /api/timesheets/{id}/meta
$meta = json_encode(['name' => 'metatestmock', 'value' => 'pwned']);
self::assertIsString($meta);
$this->request($client, '/api/timesheets/' . $id . '/meta', 'PATCH', [], $meta);
$this->assertApiResponseAccessDenied($client->getResponse());
// 10) DELETE /api/timesheets/{id} — verified last because it is destructive.
$this->request($client, '/api/timesheets/' . $id, 'DELETE');
$this->assertApiResponseAccessDenied($client->getResponse());
// The timesheet must still be in the database after every attempted attack.
self::assertNotNull(
$this->getEntityManager()->getRepository(Timesheet::class)->find($id),
'PoC: DELETE leaked through and the row was removed from the database.'
);
}
public function testCveIdorTeamleadAsPlainMemberOfOwnerTeamCannotReachAnyPerRecordRoute(): void
{
// No customer/project/activity team restriction — only the owner is in
// a team. The teamlead is a plain member of that same team. Plain
// membership must NOT be enough to reach a foreign user's timesheet
// via per-record routes (RolePermissionManager::checkTeamLeadAccess).
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$owner = $this->getUserByRole(User::ROLE_USER);
$teamlead = $this->getUserByRole(User::ROLE_TEAMLEAD);
$sharedTeam = new Team('shared');
$sharedTeam->addUser($owner);
$sharedTeam->addUser($teamlead); // plain member, not addTeamlead()
$em->persist($sharedTeam);
$timesheet = $this->persistRestrictedTimesheet($owner, [], running: false);
$id = $timesheet->getId();
self::assertIsInt($id);
$this->assertApiAccessDenied($client, '/api/timesheets/' . $id);
$patch = json_encode(['description' => 'HIJACKED_BY_BOB']);
self::assertIsString($patch);
$this->request($client, '/api/timesheets/' . $id, 'PATCH', [], $patch);
$this->assertApiResponseAccessDenied($client->getResponse());
$this->request($client, '/api/timesheets/' . $id . '/stop', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse());
$this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse());
$this->request($client, '/api/timesheets/' . $id . '/duplicate', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse());
$this->request($client, '/api/timesheets/' . $id . '/export', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse());
$meta = json_encode(['name' => 'metatestmock', 'value' => 'pwned']);
self::assertIsString($meta);
$this->request($client, '/api/timesheets/' . $id . '/meta', 'PATCH', [], $meta);
$this->assertApiResponseAccessDenied($client->getResponse());
$this->request($client, '/api/timesheets/' . $id, 'DELETE');
$this->assertApiResponseAccessDenied($client->getResponse());
}
public function testTeamleadOfOwnerTeamCanAccessTimesheetOnPerRecordRoutes(): void
{
// Positive control: when the teamlead is actually the teamlead of the
// owner's team and there is no customer/project/activity restriction,
// they pass the new team gate.
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$owner = $this->getUserByRole(User::ROLE_USER);
$teamlead = $this->getUserByRole(User::ROLE_TEAMLEAD);
$sharedTeam = new Team('shared');
$sharedTeam->addUser($owner);
$sharedTeam->addTeamlead($teamlead);
$em->persist($sharedTeam);
$timesheet = $this->persistRestrictedTimesheet($owner, [], running: false);
$id = $timesheet->getId();
self::assertIsInt($id);
// GET — read access succeeds.
$this->assertAccessIsGranted($client, '/api/timesheets/' . $id);
// PATCH — mutation succeeds.
$patch = json_encode(['description' => 'edited by teamlead']);
self::assertIsString($patch);
$this->request($client, '/api/timesheets/' . $id, 'PATCH', [], $patch);
self::assertTrue($client->getResponse()->isSuccessful(), 'PATCH should succeed when teamlead is teamlead of owner team');
// /duplicate — succeeds (project + activity visible).
$this->request($client, '/api/timesheets/' . $id . '/duplicate', 'PATCH');
self::assertTrue($client->getResponse()->isSuccessful(), 'duplicate should succeed for legitimate teamlead');
// /export — succeeds, ROLE_TEAMLEAD has edit_export_other_timesheet.
$this->request($client, '/api/timesheets/' . $id . '/export', 'PATCH');
self::assertTrue($client->getResponse()->isSuccessful(), 'export should succeed for legitimate teamlead');
}
public function testCustomerTeamRestrictionStillBlocksLegitimateTeamleadOfOwnerTeam(): void
{
// Even with the teamlead being the teamlead of the owner's team, the
// customer-level team gate must still apply. A teamlead may not bypass
// a customer's team restriction just because they happen to lead the
// owner's team.
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$owner = $this->getUserByRole(User::ROLE_USER);
$teamlead = $this->getUserByRole(User::ROLE_TEAMLEAD);
$ownerTeam = new Team('owner team');
$ownerTeam->addUser($owner);
$ownerTeam->addTeamlead($teamlead);
$em->persist($ownerTeam);
// Customer team has only the owner; the teamlead is NOT a member.
$customerTeam = new Team('customer team');
$customerTeam->addUser($owner);
$em->persist($customerTeam);
$timesheet = $this->persistRestrictedTimesheet($owner, [$customerTeam], running: false);
$id = $timesheet->getId();
self::assertIsInt($id);
$this->assertApiAccessDenied($client, '/api/timesheets/' . $id);
$patch = json_encode(['description' => 'should not work']);
self::assertIsString($patch);
$this->request($client, '/api/timesheets/' . $id, 'PATCH', [], $patch);
$this->assertApiResponseAccessDenied($client->getResponse());
$this->request($client, '/api/timesheets/' . $id, 'DELETE');
$this->assertApiResponseAccessDenied($client->getResponse());
}
public function testOwnerCanAlwaysAccessOwnTimesheetEvenWithRestrictiveTeams(): void
{
// Owner short-circuit: the team gate must NOT apply when the caller is
// also the timesheet's user. Even a customer team locked to other
// users plus an owner-only team must not prevent self-access.
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$em = $this->getEntityManager();
$owner = $this->getUserByRole(User::ROLE_USER);
$customerTeam = new Team('customer team excluding owner');
// owner is NOT a member of the customer team — checkTeamAccessProject
// would normally deny. Owner short-circuit must bypass it.
$em->persist($customerTeam);
$timesheet = $this->persistRestrictedTimesheet($owner, [$customerTeam], running: false);
$id = $timesheet->getId();
self::assertIsInt($id);
$this->assertAccessIsGranted($client, '/api/timesheets/' . $id);
$patch = json_encode(['description' => 'self edit']);
self::assertIsString($patch);
$this->request($client, '/api/timesheets/' . $id, 'PATCH', [], $patch);
self::assertTrue($client->getResponse()->isSuccessful(), 'Owner must be able to edit own timesheet');
}
public function testSuperAdminCanAccessTimesheetDespiteRestrictiveTeams(): void
{
// canSeeAllData via isSuperAdmin() — bypasses every team gate, on
// every per-record route.
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$em = $this->getEntityManager();
$owner = $this->getUserByRole(User::ROLE_USER);
$ownerTeam = new Team('owner team');
$ownerTeam->addUser($owner);
$em->persist($ownerTeam);
$customerTeam = new Team('customer team excluding super admin');
$customerTeam->addUser($owner);
$em->persist($customerTeam);
$timesheet = $this->persistRestrictedTimesheet($owner, [$customerTeam], running: false);
$id = $timesheet->getId();
self::assertIsInt($id);
$this->assertAccessIsGranted($client, '/api/timesheets/' . $id);
$patch = json_encode(['description' => 'super admin edit']);
self::assertIsString($patch);
$this->request($client, '/api/timesheets/' . $id, 'PATCH', [], $patch);
self::assertTrue($client->getResponse()->isSuccessful(), 'SUPER_ADMIN must be able to edit any timesheet');
$this->request($client, '/api/timesheets/' . $id, 'DELETE');
self::assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode());
}
public function testCveIdorOnRunningTimesheetStopRoutesAreBlocked(): void
{
// /stop targets a running timesheet. Verifies that even when the route
// would otherwise be functional, the team gate denies access.
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$owner = $this->getUserByRole(User::ROLE_USER);
$customerTeam = new Team('customer team');
$customerTeam->addUser($owner);
$em->persist($customerTeam);
$timesheet = $this->persistRestrictedTimesheet($owner, [$customerTeam], running: true);
$id = $timesheet->getId();
self::assertIsInt($id);
self::assertNull($timesheet->getEnd(), 'sanity: timesheet must be running for /stop');
$this->request($client, '/api/timesheets/' . $id . '/stop', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse());
$this->request($client, '/api/timesheets/' . $id . '/stop', 'GET');
$this->assertApiResponseAccessDenied($client->getResponse());
// Confirm side-effect-free: timesheet must still be running.
$em->clear();
$reloaded = $em->getRepository(Timesheet::class)->find($id);
self::assertInstanceOf(Timesheet::class, $reloaded);
self::assertNull($reloaded->getEnd(), '/stop must not have stopped the timesheet behind the team gate');
}
public function testTeamleadFromUnrelatedTeamCannotAccessTimesheetById(): void
{
// Mirrors the "bob-from-TeamB attacks alice-in-TeamA" PoC from the
// advisory: both users have teams, but those teams are completely
// unrelated. The attacker happens to be a teamlead of his own team.
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$owner = $this->getUserByRole(User::ROLE_USER);
$teamlead = $this->getUserByRole(User::ROLE_TEAMLEAD);
$teamA = new Team('TeamA — owner only');
$teamA->addUser($owner);
$teamB = new Team('TeamB — attacker only');
$teamB->addTeamlead($teamlead);
$customerTeam = new Team('customer team — TeamA scope');
$customerTeam->addUser($owner);
$em->persist($teamA);
$em->persist($teamB);
$em->persist($customerTeam);
$timesheet = $this->persistRestrictedTimesheet($owner, [$customerTeam], running: false);
$id = $timesheet->getId();
self::assertIsInt($id);
$this->assertApiAccessDenied($client, '/api/timesheets/' . $id);
$patch = json_encode(['description' => 'HIJACKED_BY_BOB']);
self::assertIsString($patch);
$this->request($client, '/api/timesheets/' . $id, 'PATCH', [], $patch);
$this->assertApiResponseAccessDenied($client->getResponse());
$this->request($client, '/api/timesheets/' . $id, 'DELETE');
$this->assertApiResponseAccessDenied($client->getResponse());
// Description must not have been mutated.
$em->clear();
$reloaded = $em->getRepository(Timesheet::class)->find($id);
self::assertInstanceOf(Timesheet::class, $reloaded);
self::assertSame('ALICE_SECRET', $reloaded->getDescription(), 'PATCH leaked through and rewrote the description.');
}
/**
* @param list<Team> $customerTeams teams to attach to the customer (= the project's customer)
*/
private function persistRestrictedTimesheet(User $owner, array $customerTeams, bool $running): Timesheet
{
$em = $this->getEntityManager();
$customer = new Customer('CVE-2024-29200 customer');
$customer->setCountry('DE');
$customer->setTimezone(self::TEST_TIMEZONE);
$customer->setVisible(true);
foreach ($customerTeams as $team) {
$customer->addTeam($team);
}
$em->persist($customer);
$project = new Project();
$project->setName('CVE-2024-29200 project');
$project->setCustomer($customer);
$project->setVisible(true);
$em->persist($project);
$activity = new Activity();
$activity->setName('CVE-2024-29200 activity');
$activity->setProject($project);
$activity->setVisible(true);
$em->persist($activity);
// Flush the catalog entities first so they have ids before any Doctrine
// subscriber tries to query them while persisting the timesheet
// (RateService re-loads the activity inside the timesheet onFlush hook).
$em->flush();
$timesheet = new Timesheet();
$timesheet->setUser($owner);
$timesheet->setProject($project);
$timesheet->setActivity($activity);
$timesheet->setBegin(new \DateTime('-2 hours'));
$timesheet->setDescription('ALICE_SECRET');
if (!$running) {
$end = new \DateTime('-1 hour');
$timesheet->setEnd($end);
$timesheet->setDuration(3600);
}
$em->persist($timesheet);
$em->flush();
return $timesheet;
}
/**
* @return array{0: Project, 1: Activity}
*/
private function createTeamRestrictedProjectFixture(string $suffix): array
{
$em = $this->getEntityManager();
$restrictedTeam = new Team('GHSA-vrr2 team ' . $suffix);
$restrictedTeam->addUser($this->getUserByRole(User::ROLE_TEAMLEAD));
$em->persist($restrictedTeam);
$customer = new Customer('GHSA-vrr2 customer ' . $suffix);
$customer->setCountry('DE');
$customer->setCurrency('CHF');
$customer->setTimezone(self::TEST_TIMEZONE);
$customer->setVisible(true);
$customer->addTeam($restrictedTeam);
$em->persist($customer);
$project = new Project();
$project->setName('GHSA-vrr2 project ' . $suffix);
$project->setCustomer($customer);
$project->setVisible(true);
$em->persist($project);
$activity = new Activity();
$activity->setName('GHSA-vrr2 activity ' . $suffix);
$activity->setProject($project);
$activity->setVisible(true);
$em->persist($activity);
$em->flush();
return [$project, $activity];
}
private function assertProjectIsHiddenFromApi(HttpKernelBrowser $client, Project $project): void
{
$this->assertAccessIsGranted($client, '/api/projects');
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertNotContains($project->getId(), array_column($result, 'id'));
}
private function persistFinishedTimesheet(User $owner, Project $project, Activity $activity, string $description): Timesheet
{
$timesheet = new Timesheet();
$timesheet->setUser($owner);
$timesheet->setProject($project);
$timesheet->setActivity($activity);
$timesheet->setBegin(new \DateTime('-2 hours'));
$timesheet->setEnd(new \DateTime('-1 hour'));
$timesheet->setDuration(3600);
$timesheet->setDescription($description);
$em = $this->getEntityManager();
$em->persist($timesheet);
$em->flush();
return $timesheet;
}
}

View File

@@ -11,10 +11,15 @@ namespace App\Tests\Controller;
use App\Entity\Activity;
use App\Entity\ActivityMeta;
use App\Entity\ActivityRate;
use App\Entity\Project;
use App\Entity\Role;
use App\Entity\RolePermission;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Tests\DataFixtures\ActivityFixtures;
use App\Tests\DataFixtures\CustomerFixtures;
use App\Tests\DataFixtures\ProjectFixtures;
use App\Tests\DataFixtures\TeamFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\Mocks\ActivityTestMetaFieldSubscriberMock;
@@ -191,6 +196,54 @@ class ActivityControllerTest extends AbstractControllerBaseTestCase
self::assertStringContainsString('123.45', $node->text(null, true));
}
public function testEditRateActionDeniesForeignRate(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$project = $this->getEntityManager()->getRepository(Project::class)->find(1);
self::assertInstanceOf(Project::class, $project);
$activity = $this->importFixture((new ActivityFixtures(1))->setProjects([$project]))[0];
$rate = new ActivityRate();
$rate->setActivity($activity);
$rate->setRate(123.45);
$em = $this->getEntityManager();
$em->persist($rate);
$em->flush();
$this->request($client, '/admin/activity/1/rate/' . $rate->getId());
$this->assertAccessDenied($client);
}
public function testCreateWithProjectActionDeniesUserWithoutEditProjectPermission(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$user = $this->getUserByRole(User::ROLE_USER);
$customer = $this->importFixture(new CustomerFixtures(1))[0];
$project = $this->importFixture((new ProjectFixtures(1))->setCustomers([$customer]))[0];
$em = $this->getEntityManager();
$role = (new Role())->setName('TEST_CREATE_ACTIVITY_ONLY');
$permission = (new RolePermission())->setRole($role)->setPermission('create_activity')->setAllowed(true);
$roleName = $role->getName();
self::assertNotNull($roleName);
$user->addRole($roleName);
$em->persist($role);
$em->persist($permission);
$em->persist($user);
$em->flush();
$this->request($client, '/admin/activity/create/' . $project->getId());
$this->assertAccessDenied($client);
}
public function testCreateAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -10,8 +10,8 @@
namespace App\Tests\Controller;
use App\Entity\Customer;
use App\Entity\CustomerComment;
use App\Entity\CustomerMeta;
use App\Entity\CustomerRate;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Tests\DataFixtures\CustomerFixtures;
@@ -175,6 +175,24 @@ class CustomerControllerTest extends AbstractControllerBaseTestCase
self::assertStringContainsString('123.45', $node->text(null, true));
}
public function testEditRateActionDeniesForeignRate(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$customer = $this->importFixture(new CustomerFixtures(1))[0];
$rate = new CustomerRate();
$rate->setCustomer($customer);
$rate->setRate(123.45);
$em = $this->getEntityManager();
$em->persist($rate);
$em->flush();
$this->request($client, '/admin/customer/1/rate/' . $rate->getId());
$this->assertAccessDenied($client);
}
public function testAddCommentAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
@@ -198,78 +216,6 @@ class CustomerControllerTest extends AbstractControllerBaseTestCase
self::assertStringContainsString('<p>A beautiful and short comment <strong>with some</strong> markdown formatting</p>', $node->html());
}
public function testDeleteCommentAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/customer/1/details');
$form = $client->getCrawler()->filter('form[name=customer_comment_form]')->form();
$client->submit($form, [
'customer_comment_form' => [
'message' => 'Blah foo bar',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.card#comments_box .card-body');
self::assertStringContainsString('Blah foo bar', $node->html());
$node = $client->getCrawler()->filter('div.card#comments_box .card-body a.delete-comment-link');
$this->request($client, $node->attr('href'));
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.card#comments_box .card-body');
self::assertStringContainsString('There were no comments posted yet', $node->html());
}
public function testDeleteCommentActionWithoutToken(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/customer/1/details');
$form = $client->getCrawler()->filter('form[name=customer_comment_form]')->form();
$client->submit($form, [
'customer_comment_form' => [
'message' => 'Blah foo bar',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$comments = $this->getEntityManager()->getRepository(CustomerComment::class)->findAll();
$id = $comments[0]->getId();
$this->request($client, '/admin/customer/' . $id . '/comment_delete');
$this->assertRouteNotFound($client);
}
public function testPinCommentAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/customer/1/details');
$form = $client->getCrawler()->filter('form[name=customer_comment_form]')->form();
$client->submit($form, [
'customer_comment_form' => [
'message' => 'Blah foo bar',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.card#comments_box .card-body');
self::assertStringContainsString('Blah foo bar', $node->html());
$node = $client->getCrawler()->filter('div.card#comments_box .card-body a.pin-comment-link.active');
self::assertEquals(0, $node->count());
$node = $client->getCrawler()->filter('div.card#comments_box .card-body a.pin-comment-link');
self::assertEquals(1, $node->count());
$this->request($client, $node->attr('href'));
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.card#comments_box .card-body a.pin-comment-link.active');
self::assertEquals(1, $node->count());
self::assertStringContainsString('/admin/customer/', $node->attr('href'));
self::assertStringContainsString('/comment_pin/', $node->attr('href'));
}
public function testCreateDefaultTeamAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -10,7 +10,9 @@
namespace App\Tests\Controller;
use App\Entity\User;
use App\Repository\BookmarkRepository;
use App\Tests\DataFixtures\TimesheetFixtures;
use App\Timesheet\FavoriteRecordService;
use PHPUnit\Framework\Attributes\Group;
#[Group('integration')]
@@ -41,4 +43,119 @@ class FavoriteControllerTest extends AbstractControllerBaseTestCase
self::assertStringContainsString('<a class="api-link text-decoration-none text-body d-block" href="/api/timesheets/', $content);
self::assertStringContainsString('data-event="kimai.timesheetStart kimai.timesheetUpdate" data-method="PATCH" data-msg-error="timesheet', $content);
}
/**
* Regression test for the security issue in FavoriteController::add():
* an unprivileged user must NOT be able to add a favorite for a timesheet
* owned by another user, even if they know a valid timesheet ID.
*/
public function testAddFavoriteForOtherUsersTimesheetIsDenied(): void
{
// attacker is a plain user (ROLE_USER), not the timesheet owner
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$victim = $this->getUserByRole(User::ROLE_TEAMLEAD);
$fixture = new TimesheetFixtures();
$fixture->setAmount(1);
$fixture->setUser($victim);
$timesheets = $this->importFixture($fixture);
$timesheetId = $timesheets[0]->getId();
self::assertNotNull($timesheetId);
$this->request($client, '/favorite/timesheet/add/' . $timesheetId);
$this->assertAccessDenied($client);
// the victim's bookmark must not have been touched
/** @var BookmarkRepository $bookmarkRepository */
$bookmarkRepository = $this->getPrivateService(BookmarkRepository::class);
$this->getEntityManager()->clear();
$bookmark = $bookmarkRepository->findBookmark($this->getUserByRole(User::ROLE_TEAMLEAD), 'favorite', 'recent');
if ($bookmark !== null) {
self::assertNotContains($timesheetId, $bookmark->getContent(), 'attacker must not write to the victim\'s bookmark');
}
}
/**
* Regression test for the security issue in FavoriteController::remove():
* an unprivileged user must NOT be able to remove a favorite from another
* user's bookmark, even if they know a valid timesheet ID.
*/
public function testRemoveFavoriteForOtherUsersTimesheetIsDenied(): void
{
// attacker is a plain user (ROLE_USER), not the timesheet owner
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$victim = $this->getUserByRole(User::ROLE_TEAMLEAD);
$fixture = new TimesheetFixtures();
$fixture->setAmount(1);
$fixture->setUser($victim);
$timesheets = $this->importFixture($fixture);
$timesheet = $timesheets[0];
$timesheetId = $timesheet->getId();
self::assertNotNull($timesheetId);
// legitimately seed the victim's own favorites
/** @var FavoriteRecordService $favoriteRecordService */
$favoriteRecordService = $this->getPrivateService(FavoriteRecordService::class);
$favoriteRecordService->addFavorite($timesheet);
/** @var BookmarkRepository $bookmarkRepository */
$bookmarkRepository = $this->getPrivateService(BookmarkRepository::class);
$this->getEntityManager()->clear();
$bookmark = $bookmarkRepository->findBookmark($this->getUserByRole(User::ROLE_TEAMLEAD), 'favorite', 'recent');
self::assertNotNull($bookmark);
self::assertContains($timesheetId, $bookmark->getContent(), 'precondition: favorite exists for the victim');
// attacker (ROLE_USER) attempts to remove the favorite from the victim's bookmark
$this->request($client, '/favorite/timesheet/remove/' . $timesheetId);
$this->assertAccessDenied($client);
// the victim's favorite must still be there
/** @var BookmarkRepository $bookmarkRepository */
$bookmarkRepository = $this->getPrivateService(BookmarkRepository::class);
$this->getEntityManager()->clear();
$bookmark = $bookmarkRepository->findBookmark($this->getUserByRole(User::ROLE_TEAMLEAD), 'favorite', 'recent');
self::assertNotNull($bookmark);
self::assertContains($timesheetId, $bookmark->getContent(), 'attacker must not remove the victim\'s favorite');
}
/**
* Ensures the added `#[IsGranted('view', 'timesheet')]` voter does not
* break the legitimate use case: a user managing favorites for their own
* timesheet.
*/
public function testAddAndRemoveFavoriteForOwnTimesheetIsAllowed(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$owner = $this->getUserByRole(User::ROLE_USER);
$fixture = new TimesheetFixtures();
$fixture->setAmount(1);
$fixture->setUser($owner);
$timesheets = $this->importFixture($fixture);
$timesheetId = $timesheets[0]->getId();
self::assertNotNull($timesheetId);
$this->request($client, '/favorite/timesheet/add/' . $timesheetId);
self::assertTrue($client->getResponse()->isRedirect());
/** @var BookmarkRepository $bookmarkRepository */
$bookmarkRepository = $this->getPrivateService(BookmarkRepository::class);
$this->getEntityManager()->clear();
$bookmark = $bookmarkRepository->findBookmark($this->getUserByRole(User::ROLE_USER), 'favorite', 'recent');
self::assertNotNull($bookmark);
self::assertContains($timesheetId, $bookmark->getContent());
$this->request($client, '/favorite/timesheet/remove/' . $timesheetId);
self::assertTrue($client->getResponse()->isRedirect());
/** @var BookmarkRepository $bookmarkRepository */
$bookmarkRepository = $this->getPrivateService(BookmarkRepository::class);
$this->getEntityManager()->clear();
$bookmark = $bookmarkRepository->findBookmark($this->getUserByRole(User::ROLE_USER), 'favorite', 'recent');
self::assertNotNull($bookmark);
self::assertNotContains($timesheetId, $bookmark->getContent());
}
}

View File

@@ -15,10 +15,13 @@ use App\Entity\ActivityRate;
use App\Entity\Project;
use App\Entity\ProjectMeta;
use App\Entity\ProjectRate;
use App\Entity\Role;
use App\Entity\RolePermission;
use App\Entity\Team;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Tests\DataFixtures\ActivityFixtures;
use App\Tests\DataFixtures\CustomerFixtures;
use App\Tests\DataFixtures\ProjectFixtures;
use App\Tests\DataFixtures\TeamFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
@@ -212,6 +215,24 @@ class ProjectControllerTest extends AbstractControllerBaseTestCase
$this->assertAddRate($client, 123.45, 1);
}
public function testEditRateActionDeniesForeignRate(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$project = $this->importFixture(new ProjectFixtures(1))[0];
$rate = new ProjectRate();
$rate->setProject($project);
$rate->setRate(123.45);
$em = $this->getEntityManager();
$em->persist($rate);
$em->flush();
$this->request($client, '/admin/project/1/rate/' . $rate->getId());
$this->assertAccessDenied($client);
}
public function assertAddRate(HttpKernelBrowser $client, $rate, $projectId): void
{
$this->assertAccessIsGranted($client, '/admin/project/' . $projectId . '/rate');
@@ -310,57 +331,6 @@ class ProjectControllerTest extends AbstractControllerBaseTestCase
self::assertStringContainsString('<p>A beautiful and long comment <strong>with some</strong> markdown formatting</p>', $node->html());
}
public function testDeleteCommentAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/project/1/details');
$form = $client->getCrawler()->filter('form[name=project_comment_form]')->form();
$client->submit($form, [
'project_comment_form' => [
'message' => 'Foo bar blub',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.card#comments_box .card-body');
self::assertStringContainsString('Foo bar blub', $node->html());
$node = $client->getCrawler()->filter('div.card#comments_box .card-body a.delete-comment-link');
$this->request($client, $node->attr('href'));
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.card#comments_box .card-body');
self::assertStringContainsString('There were no comments posted yet', $node->html());
}
public function testPinCommentAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/project/1/details');
$form = $client->getCrawler()->filter('form[name=project_comment_form]')->form();
$client->submit($form, [
'project_comment_form' => [
'message' => 'Foo bar blub',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.card#comments_box .card-body');
self::assertStringContainsString('Foo bar blub', $node->html());
$node = $client->getCrawler()->filter('div.card#comments_box .card-body a.pin-comment-link.active');
self::assertEquals(0, $node->count());
$node = $client->getCrawler()->filter('div.card#comments_box .card-body a.pin-comment-link');
self::assertEquals(1, $node->count());
$this->request($client, $node->attr('href'));
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.card#comments_box .card-body a.pin-comment-link.active');
self::assertEquals(1, $node->count());
self::assertStringContainsString('/admin/project/', $node->attr('href'));
self::assertStringContainsString('/comment_pin/', $node->attr('href'));
}
public function testCreateDefaultTeamAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
@@ -403,6 +373,32 @@ class ProjectControllerTest extends AbstractControllerBaseTestCase
self::assertEquals(5, $node->count());
}
public function testCreateWithCustomerActionDeniesUserWithoutEditCustomerPermission(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$user = $this->getUserByRole(User::ROLE_USER);
$customer = $this->importFixture(new CustomerFixtures(1))[0];
$em = $this->getEntityManager();
$role = (new Role())->setName('TEST_CREATE_PROJECT_ONLY');
$permission = (new RolePermission())->setRole($role)->setPermission('create_project')->setAllowed(true);
$roleName = $role->getName();
self::assertNotNull($roleName);
$user->addRole($roleName);
$em->persist($role);
$em->persist($permission);
$em->persist($user);
$em->flush();
$this->request($client, '/admin/project/create/' . $customer->getId());
$this->assertAccessDenied($client);
}
public function testCreateAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -142,8 +142,11 @@ class CustomerServiceTest extends TestCase
$sut->saveCustomer($Customer);
}
/**
* @param \Closure(\DateTimeInterface): string $expected
*/
#[DataProvider('getTestData')]
public function testCustomerNumber(string $format, int|string $expected): void
public function testCustomerNumber(string $format, \Closure $expected): void
{
$configuration = SystemConfigurationFactory::createStub([
'defaults' => [
@@ -159,64 +162,64 @@ class CustomerServiceTest extends TestCase
]);
$sut = $this->getSut(null, null, $configuration);
$date = new \DateTimeImmutable();
$customer = $sut->createNewCustomer('Test');
self::assertEquals((string) $expected, $customer->getNumber());
self::assertEquals($expected($date), $customer->getNumber());
}
/**
* @return array<int, array<int, string|\DateTime|int>>
* @return array<int, array{0: string, 1: \Closure(\DateTimeInterface): string}>
*/
public static function getTestData(): array
{
$dateTime = new \DateTime();
$yearLong = (int) $dateTime->format('Y');
$yearShort = (int) $dateTime->format('y');
$monthLong = $dateTime->format('m');
$monthShort = (int) $dateTime->format('n');
$dayLong = $dateTime->format('d');
$dayShort = (int) $dateTime->format('j');
$literal = static fn (string $value): \Closure => static fn (): string => $value;
$date = static fn (string $format): \Closure => static fn (\DateTimeInterface $d): string => $d->format($format);
$yearLong = static fn (int $add): \Closure => static fn (\DateTimeInterface $d): string => (string) ((int) $d->format('Y') + $add);
$yearShort = static fn (int $add): \Closure => static fn (\DateTimeInterface $d): string => (string) ((int) $d->format('y') + $add);
$monthShort = static fn (int $add): \Closure => static fn (\DateTimeInterface $d): string => (string) ((int) $d->format('m') + $add);
$dayShort = static fn (int $add): \Closure => static fn (\DateTimeInterface $d): string => (string) ((int) $d->format('d') + $add);
return [
// simple tests for single calls
['{cc,1}', '2'],
['{cc,2}', '02'],
['{cc,3}', '002'],
['{cc,4}', '0002'],
['{Y}', $yearLong],
['{y}', $yearShort],
['{M}', $monthLong],
['{m}', $monthShort],
['{D}', $dayLong],
['{d}', $dayShort],
// number formatting (not testing the lower case versions, as the tests might break depending on the date)
['{Y,6}', '00' . $yearLong],
['{M,3}', '0' . $monthLong],
['{D,3}', '0' . $dayLong],
['{cc,1}', $literal('2')],
['{cc,2}', $literal('02')],
['{cc,3}', $literal('002')],
['{cc,4}', $literal('0002')],
['{Y}', $date('Y')],
['{y}', $date('y')],
['{M}', $date('m')],
['{m}', $date('n')],
['{D}', $date('d')],
['{d}', $date('j')],
// number formatting
['{Y,6}', static fn (\DateTimeInterface $d): string => '00' . $d->format('Y')],
['{M,3}', static fn (\DateTimeInterface $d): string => '0' . $d->format('m')],
['{D,3}', static fn (\DateTimeInterface $d): string => '0' . $d->format('d')],
// increment dates
['{YY}', $yearLong + 1],
['{YY+1}', $yearLong + 1],
['{YY+2}', $yearLong + 2],
['{YY+3}', $yearLong + 3],
['{YY-1}', $yearLong - 1],
['{YY-2}', $yearLong - 2],
['{YY-3}', $yearLong - 3],
['{yy}', $yearShort + 1],
['{yy+1}', $yearShort + 1],
['{yy+2}', $yearShort + 2],
['{yy+3}', $yearShort + 3],
['{yy-1}', $yearShort - 1],
['{yy-2}', $yearShort - 2],
['{yy-3}', $yearShort - 3],
['{MM}', $monthShort + 1], // cast to int removes leading zero
['{MM+1}', $monthShort + 1], // cast to int removes leading zero
['{MM+2}', $monthShort + 2], // cast to int removes leading zero
['{MM+3}', $monthShort + 3], // cast to int removes leading zero
['{DD}', $dayShort + 1], // cast to int removes leading zero
['{DD+1}', $dayShort + 1], // cast to int removes leading zero
['{DD+2}', $dayShort + 2], // cast to int removes leading zero
['{DD+3}', $dayShort + 3], // cast to int removes leading zero
['{YY}', $yearLong(1)],
['{YY+1}', $yearLong(1)],
['{YY+2}', $yearLong(2)],
['{YY+3}', $yearLong(3)],
['{YY-1}', $yearLong(-1)],
['{YY-2}', $yearLong(-2)],
['{YY-3}', $yearLong(-3)],
['{yy}', $yearShort(1)],
['{yy+1}', $yearShort(1)],
['{yy+2}', $yearShort(2)],
['{yy+3}', $yearShort(3)],
['{yy-1}', $yearShort(-1)],
['{yy-2}', $yearShort(-2)],
['{yy-3}', $yearShort(-3)],
['{MM}', $monthShort(1)], // cast to int removes leading zero
['{MM+1}', $monthShort(1)], // cast to int removes leading zero
['{MM+2}', $monthShort(2)], // cast to int removes leading zero
['{MM+3}', $monthShort(3)], // cast to int removes leading zero
['{DD}', $dayShort(1)], // cast to int removes leading zero
['{DD+1}', $dayShort(1)], // cast to int removes leading zero
['{DD+2}', $dayShort(2)], // cast to int removes leading zero
['{DD+3}', $dayShort(3)], // cast to int removes leading zero
];
}
}

View File

@@ -11,6 +11,7 @@ namespace App\Tests\DependencyInjection;
use App\DependencyInjection\Configuration;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
@@ -125,6 +126,120 @@ class ConfigurationTest extends TestCase
$this->assertConfig($config, []);
}
public static function provideValidThemeConfigurations(): iterable
{
yield 'authentication auto theme' => [
[
'user' => [
'theme' => 'auto',
],
],
['user', 'theme'],
'auto',
];
yield 'authentication default theme' => [
[
'user' => [
'theme' => 'default',
],
],
['user', 'theme'],
'default',
];
yield 'authentication dark theme' => [
[
'user' => [
'theme' => 'dark',
],
],
['user', 'theme'],
'dark',
];
yield 'user default auto theme' => [
[
'defaults' => [
'user' => [
'theme' => 'auto',
],
],
],
['defaults', 'user', 'theme'],
'auto',
];
yield 'user default default theme' => [
[
'defaults' => [
'user' => [
'theme' => 'default',
],
],
],
['defaults', 'user', 'theme'],
'default',
];
yield 'user default dark theme' => [
[
'defaults' => [
'user' => [
'theme' => 'dark',
],
],
],
['defaults', 'user', 'theme'],
'dark',
];
}
#[DataProvider('provideValidThemeConfigurations')]
public function testValidateThemeAllowsSupportedValues(array $input, array $path, string $expected): void
{
$config = array_replace_recursive($this->getMinConfig(), $input);
$compiled = $this->getCompiledConfig($config);
self::assertSame($expected, array_reduce($path, static function ($value, $key) {
return $value[$key];
}, $compiled));
}
public static function provideInvalidThemeConfigurations(): iterable
{
yield 'authentication invalid theme' => [
[
'user' => [
'theme' => 'blue',
],
],
'kimai.user.theme',
];
yield 'user default invalid theme' => [
[
'defaults' => [
'user' => [
'theme' => 'blue',
],
],
],
'kimai.defaults.user.theme',
];
}
#[DataProvider('provideInvalidThemeConfigurations')]
public function testValidateThemeRejectsUnsupportedValues(array $input, string $path): void
{
$this->expectException(InvalidConfigurationException::class);
$this->expectExceptionMessage(\sprintf('Invalid configuration for path "%s": The theme must be one of: "auto", "default", "dark"', $path));
$config = array_replace_recursive($this->getMinConfig(), $input);
$this->assertConfig($config, []);
}
public function testValidateLdapFilterInvalidParenthesisCounter(): void
{
$this->expectException(InvalidConfigurationException::class);
@@ -300,6 +415,7 @@ class ConfigurationTest extends TestCase
'login' => true,
'password_reset_retry_ttl' => 3600,
'password_reset_token_ttl' => 86400,
'theme' => 'auto',
],
'invoice' => [
'documents' => [

View File

@@ -481,6 +481,74 @@ class UserTest extends TestCase
self::assertFalse($sut->initCanSeeAllData(true));
}
public function testIsRegularUserOnly(): void
{
$sut = new User();
self::assertTrue($sut->isRegularUserOnly());
$sut->setRoles([User::ROLE_USER]);
self::assertTrue($sut->isRegularUserOnly());
$sut->addRole(User::ROLE_TEAMLEAD);
self::assertFalse($sut->isRegularUserOnly());
$sut->removeRole(User::ROLE_TEAMLEAD);
self::assertTrue($sut->isRegularUserOnly());
}
/**
* @deprecated
*/
#[Group('legacy')]
public function testCanSeeUserGrantsAccessForTeamleadToRegularUserWithoutTeam(): void
{
$requester = new User();
$requester->setRoles([User::ROLE_TEAMLEAD]);
$subject = self::userWithId(1);
$subject->setEnabled(true);
self::assertTrue($subject->isRegularUserOnly());
self::assertSame([], $subject->getTeams());
self::assertTrue($requester->canSeeUser($subject));
}
/**
* @deprecated
*/
#[Group('legacy')]
public function testCanSeeUserDeniesTeamleadFallbackForRegularUserWithTeam(): void
{
$requester = new User();
$requester->setRoles([User::ROLE_TEAMLEAD]);
$subject = self::userWithId(1);
$subject->setEnabled(true);
(new Team('Support'))->addUser($subject);
self::assertTrue($subject->isRegularUserOnly());
self::assertNotSame([], $subject->getTeams());
self::assertFalse($requester->canSeeUser($subject));
}
/**
* @deprecated
*/
#[Group('legacy')]
public function testCanSeeUserDeniesTeamleadFallbackForNonRegularUserWithoutTeam(): void
{
$requester = new User();
$requester->setRoles([User::ROLE_TEAMLEAD]);
$subject = self::userWithId(1);
$subject->setEnabled(true);
$subject->addRole(User::ROLE_ADMIN);
self::assertFalse($subject->isRegularUserOnly());
self::assertSame([], $subject->getTeams());
self::assertFalse($requester->canSeeUser($subject));
}
public function testSystemAccount(): void
{
$sut = new User();
@@ -703,11 +771,20 @@ class UserTest extends TestCase
self::assertTrue($user->isPasswordRequestNonExpired(3600));
self::assertTrue($user->isPasswordRequestNonExpired(7200));
$before = date_default_timezone_get();
date_default_timezone_set('America/Los_Angeles');
date_default_timezone_set($before);
$user->setTimezone('America/Los_Angeles');
self::assertTrue($user->isPasswordRequestNonExpired(3600));
self::assertTrue($user->isPasswordRequestNonExpired(7200));
}
private static function userWithId(int $id): User
{
$user = new User();
$reflection = new \ReflectionClass($user);
$property = $reflection->getProperty('id');
$property->setAccessible(true);
$property->setValue($user, $id);
return $user;
}
}

View File

@@ -0,0 +1,127 @@
<?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\EventSubscriber;
use App\Configuration\LocaleService;
use App\Entity\User;
use App\Entity\UserPreference;
use App\EventSubscriber\ThemeOptionsSubscriber;
use App\Tests\Mocks\SystemConfigurationFactory;
use KevinPapst\TablerBundle\Helper\ContextHelper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\KernelEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[CoversClass(ThemeOptionsSubscriber::class)]
class ThemeOptionsSubscriberTest extends TestCase
{
public function testGetSubscribedEvents(): void
{
self::assertEquals([KernelEvents::CONTROLLER => ['setThemeOptions', 100]], ThemeOptionsSubscriber::getSubscribedEvents());
}
public function testUsesAuthenticationThemeWithoutAuthenticatedUser(): void
{
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn(null);
$helper = new ContextHelper();
$sut = $this->createSut($storage, $helper, ['user' => ['theme' => 'dark']]);
$sut->setThemeOptions($this->createMainRequestEvent('ar'));
self::assertTrue($helper->isRightToLeft());
self::assertTrue($helper->isDarkMode());
self::assertFalse($helper->isThemeAuto());
self::assertTrue($helper->isHeaderDark());
self::assertTrue($helper->isNavbarDark());
self::assertFalse($helper->isBoxedLayout());
self::assertFalse($helper->isCondensedUserMenu());
self::assertFalse($helper->isCondensedNavbar());
self::assertFalse($helper->isNavbarOverlapping());
}
public function testUsesAuthenticationThemeForNonKimaiUserToken(): void
{
$securityUser = $this->createMock(UserInterface::class);
$token = $this->createMock(TokenInterface::class);
$token->expects($this->once())->method('getUser')->willReturn($securityUser);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$helper = new ContextHelper();
$sut = $this->createSut($storage, $helper, ['user' => ['theme' => 'auto']]);
$sut->setThemeOptions($this->createMainRequestEvent());
self::assertFalse($helper->isDarkMode());
self::assertTrue($helper->isThemeAuto());
self::assertFalse($helper->isHeaderDark());
}
public function testUserThemeOverridesAuthenticationTheme(): void
{
$user = new User();
$user->setPreferenceValue(UserPreference::SKIN, 'dark');
$token = $this->createMock(TokenInterface::class);
$token->expects($this->once())->method('getUser')->willReturn($user);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$helper = new ContextHelper();
$sut = $this->createSut($storage, $helper, ['user' => ['theme' => 'auto']]);
$sut->setThemeOptions($this->createMainRequestEvent());
self::assertFalse($helper->isRightToLeft());
self::assertTrue($helper->isDarkMode());
self::assertFalse($helper->isThemeAuto());
self::assertTrue($helper->isHeaderDark());
}
private function createSut(TokenStorageInterface $storage, ContextHelper $helper, array $settings = []): ThemeOptionsSubscriber
{
return new ThemeOptionsSubscriber(
$storage,
$helper,
new LocaleService([
'en' => LocaleService::DEFAULT_SETTINGS,
'ar' => [
'date' => 'dd.MM.y',
'time' => 'H:mm',
'rtl' => true,
'translation' => false,
],
]),
SystemConfigurationFactory::createStub($settings)
);
}
private function createMainRequestEvent(string $locale = 'en'): KernelEvent
{
$request = new Request();
$request->setLocale($locale);
$event = $this->createMock(KernelEvent::class);
$event->expects($this->once())->method('isMainRequest')->willReturn(true);
$event->expects($this->once())->method('getRequest')->willReturn($request);
return $event;
}
}

View File

@@ -28,6 +28,22 @@ class InvoiceModelDefaultHydratorTest extends TestCase
$result = $sut->hydrate($model);
$this->assertModelStructure($result);
self::assertSame(
$model->getFormatter()->getFormattedDateTime($model->getInvoicePeriod()->getStart()),
$result['invoice.first']
);
self::assertSame(
$model->getInvoicePeriod()->getStart()->format('Y-m-d h:i:s'),
$result['invoice.first_process']
);
self::assertSame(
$model->getFormatter()->getFormattedDateTime($model->getInvoicePeriod()->getEnd()),
$result['invoice.last']
);
self::assertSame(
$model->getInvoicePeriod()->getEnd()->format('Y-m-d h:i:s'),
$result['invoice.last_process']
);
}
public function testHydrateThrowsOnMissing(): void
@@ -67,8 +83,12 @@ class InvoiceModelDefaultHydratorTest extends TestCase
'invoice.total_time',
'invoice.duration_decimal',
'invoice.first',
'invoice.first_month',
'invoice.first_year',
'invoice.first_process',
'invoice.last',
'invoice.last_month',
'invoice.last_year',
'invoice.last_process',
'invoice.total',
'invoice.total_nc',

View File

@@ -33,6 +33,7 @@ class InvoiceModelProjectHydratorTest extends TestCase
public function assertModelStructure(array $model): void
{
$keys = [
'project._counter',
'project.id',
'project.name',
'project.comment',

View File

@@ -13,15 +13,21 @@ use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\InvoiceModel;
use App\Invoice\InvoicePeriod;
use App\Repository\Query\InvoiceQuery;
use App\Tests\Invoice\NumberGenerator\IncrementingNumberGenerator;
use App\Tests\Invoice\Renderer\RendererTestTrait;
use App\Tests\Mocks\InvoiceModelFactoryFactory;
use App\Timesheet\RateCalculator\DecimalRateCalculator;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(InvoiceModel::class)]
#[CoversClass(InvoicePeriod::class)]
class InvoiceModelTest extends TestCase
{
use RendererTestTrait;
public function testEmptyObject(): void
{
$formatter = new DebugFormatter();
@@ -119,4 +125,50 @@ class InvoiceModelTest extends TestCase
$expected = new \DateTimeImmutable('2022-06-06');
self::assertEquals($expected->format('Y-m-d'), $dueDate->format('Y-m-d'));
}
public function testGetInvoicePeriod(): void
{
$sut = $this->getInvoiceModel();
$period = $sut->getInvoicePeriod();
self::assertSame('2020-08-12 18:00:00', $period->getStart()->format('Y-m-d H:i:s'));
self::assertSame('2021-03-12 12:17:40', $period->getEnd()->format('Y-m-d H:i:s'));
}
public function testGetInvoicePeriodFallsBackToQueryDates(): void
{
$query = new InvoiceQuery();
$query->setBegin(new \DateTime('2022-01-02 03:04:05'));
$query->setEnd(new \DateTime('2022-06-07 08:09:10'));
$sut = (new InvoiceModelFactoryFactory($this))->create()->createModel(
new DebugFormatter(),
new Customer('foo'),
new InvoiceTemplate(),
$query
);
$period = $sut->getInvoicePeriod();
self::assertSame('2022-01-02 00:00:00', $period->getStart()->format('Y-m-d H:i:s'));
self::assertSame('2022-06-07 23:59:59', $period->getEnd()->format('Y-m-d H:i:s'));
}
public function testGetInvoicePeriodFallsBackToInvoiceDateWithoutQuery(): void
{
$invoiceDate = new \DateTimeImmutable('2023-09-10 11:12:13');
$sut = new InvoiceModel(
new DebugFormatter(),
new Customer('foo'),
new InvoiceTemplate(),
new DecimalRateCalculator()
);
$sut->setInvoiceDate($invoiceDate);
$period = $sut->getInvoicePeriod();
self::assertSame('2023-09-10 11:12:13', $period->getStart()->format('Y-m-d H:i:s'));
self::assertSame('2023-09-10 11:12:13', $period->getEnd()->format('Y-m-d H:i:s'));
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Invoice;
use App\Invoice\InvoicePeriod;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(InvoicePeriod::class)]
class InvoicePeriodTest extends TestCase
{
public function testGetters(): void
{
$start = new \DateTimeImmutable('2024-01-02 03:04:05');
$end = new \DateTimeImmutable('2024-06-07 08:09:10');
$sut = new InvoicePeriod($start, $end);
self::assertSame($start, $sut->getStart());
self::assertSame($end, $sut->getEnd());
}
}

View File

@@ -104,8 +104,12 @@ class DebugRendererTest extends TestCase
'invoice.total_time',
'invoice.duration_decimal',
'invoice.first',
'invoice.first_month',
'invoice.first_year',
'invoice.first_process',
'invoice.last',
'invoice.last_month',
'invoice.last_year',
'invoice.last_process',
'invoice.total',
'invoice.total_nc',
@@ -210,6 +214,7 @@ class DebugRendererTest extends TestCase
'user.meta.hello',
'user.meta.kitty',
'testFromModelHydrator',
'project._counter',
];
if ($activityCounter === 1) {

View File

@@ -172,8 +172,11 @@ class ProjectServiceTest extends TestCase
self::assertNull($project->getCustomer());
}
/**
* @param \Closure(\DateTimeInterface): string $expected
*/
#[DataProvider('getTestData')]
public function testProjectNumber(string $format, int|string $expected): void
public function testProjectNumber(string $format, \Closure $expected): void
{
$configuration = SystemConfigurationFactory::createStub([
'project' => [
@@ -183,64 +186,64 @@ class ProjectServiceTest extends TestCase
]);
$sut = $this->getSut(null, null, $configuration);
$date = new \DateTimeImmutable();
$project = $sut->createNewProject();
self::assertEquals((string) $expected, $project->getNumber());
self::assertEquals($expected($date), $project->getNumber());
}
/**
* @return array<int, array<int, string|\DateTime|int>>
* @return array<int, array{0: string, 1: \Closure(\DateTimeInterface): string}>
*/
public static function getTestData(): array
{
$dateTime = new \DateTime();
$yearLong = (int) $dateTime->format('Y');
$yearShort = (int) $dateTime->format('y');
$monthLong = $dateTime->format('m');
$monthShort = (int) $dateTime->format('n');
$dayLong = $dateTime->format('d');
$dayShort = (int) $dateTime->format('j');
$literal = static fn (string $value): \Closure => static fn (): string => $value;
$date = static fn (string $format): \Closure => static fn (\DateTimeInterface $d): string => $d->format($format);
$yearLong = static fn (int $add): \Closure => static fn (\DateTimeInterface $d): string => (string) ((int) $d->format('Y') + $add);
$yearShort = static fn (int $add): \Closure => static fn (\DateTimeInterface $d): string => (string) ((int) $d->format('y') + $add);
$monthShort = static fn (int $add): \Closure => static fn (\DateTimeInterface $d): string => (string) ((int) $d->format('m') + $add);
$dayShort = static fn (int $add): \Closure => static fn (\DateTimeInterface $d): string => (string) ((int) $d->format('d') + $add);
return [
// simple tests for single calls
['{pc,1}', '2'],
['{pc,2}', '02'],
['{pc,3}', '002'],
['{pc,4}', '0002'],
['{Y}', $yearLong],
['{y}', $yearShort],
['{M}', $monthLong],
['{m}', $monthShort],
['{D}', $dayLong],
['{d}', $dayShort],
// number formatting (not testing the lower case versions, as the tests might break depending on the date)
['{Y,6}', '00' . $yearLong],
['{M,3}', '0' . $monthLong],
['{D,3}', '0' . $dayLong],
['{pc,1}', $literal('2')],
['{pc,2}', $literal('02')],
['{pc,3}', $literal('002')],
['{pc,4}', $literal('0002')],
['{Y}', $date('Y')],
['{y}', $date('y')],
['{M}', $date('m')],
['{m}', $date('n')],
['{D}', $date('d')],
['{d}', $date('j')],
// number formatting
['{Y,6}', static fn (\DateTimeInterface $d): string => '00' . $d->format('Y')],
['{M,3}', static fn (\DateTimeInterface $d): string => '0' . $d->format('m')],
['{D,3}', static fn (\DateTimeInterface $d): string => '0' . $d->format('d')],
// increment dates
['{YY}', $yearLong + 1],
['{YY+1}', $yearLong + 1],
['{YY+2}', $yearLong + 2],
['{YY+3}', $yearLong + 3],
['{YY-1}', $yearLong - 1],
['{YY-2}', $yearLong - 2],
['{YY-3}', $yearLong - 3],
['{yy}', $yearShort + 1],
['{yy+1}', $yearShort + 1],
['{yy+2}', $yearShort + 2],
['{yy+3}', $yearShort + 3],
['{yy-1}', $yearShort - 1],
['{yy-2}', $yearShort - 2],
['{yy-3}', $yearShort - 3],
['{MM}', $monthShort + 1], // cast to int removes leading zero
['{MM+1}', $monthShort + 1], // cast to int removes leading zero
['{MM+2}', $monthShort + 2], // cast to int removes leading zero
['{MM+3}', $monthShort + 3], // cast to int removes leading zero
['{DD}', $dayShort + 1], // cast to int removes leading zero
['{DD+1}', $dayShort + 1], // cast to int removes leading zero
['{DD+2}', $dayShort + 2], // cast to int removes leading zero
['{DD+3}', $dayShort + 3], // cast to int removes leading zero
['{YY}', $yearLong(1)],
['{YY+1}', $yearLong(1)],
['{YY+2}', $yearLong(2)],
['{YY+3}', $yearLong(3)],
['{YY-1}', $yearLong(-1)],
['{YY-2}', $yearLong(-2)],
['{YY-3}', $yearLong(-3)],
['{yy}', $yearShort(1)],
['{yy+1}', $yearShort(1)],
['{yy+2}', $yearShort(2)],
['{yy+3}', $yearShort(3)],
['{yy-1}', $yearShort(-1)],
['{yy-2}', $yearShort(-2)],
['{yy-3}', $yearShort(-3)],
['{MM}', $monthShort(1)], // cast to int removes leading zero
['{MM+1}', $monthShort(1)], // cast to int removes leading zero
['{MM+2}', $monthShort(2)], // cast to int removes leading zero
['{MM+3}', $monthShort(3)], // cast to int removes leading zero
['{DD}', $dayShort(1)], // cast to int removes leading zero
['{DD+1}', $dayShort(1)], // cast to int removes leading zero
['{DD+2}', $dayShort(2)], // cast to int removes leading zero
['{DD+3}', $dayShort(3)], // cast to int removes leading zero
];
}
}

View File

@@ -807,6 +807,164 @@ class RolePermissionManagerTest extends TestCase
self::assertFalse($sut->checkTeamAccessTimesheet($timesheet, $requester));
}
public function testCheckUserAccessGrantsAccessForSameUserId(): void
{
$sut = $this->createSut();
$subject = self::userWithId(42);
$subject->setEnabled(false);
$subject->setSystemAccount(true);
$requester = self::userWithId(42);
self::assertTrue($sut->checkUserAccess($subject, $requester));
}
public function testCheckUserAccessGrantsAccessForSuperAdmin(): void
{
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(true);
$requester = self::userWithId(2);
$requester->setSuperAdmin(true);
self::assertTrue($sut->checkUserAccess($subject, $requester));
}
public function testCheckUserAccessGrantsAccessForCanSeeAllData(): void
{
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(true);
$requester = self::userWithId(2);
$requester->initCanSeeAllData(true);
self::assertTrue($sut->checkUserAccess($subject, $requester));
}
public function testCheckUserAccessDeniesDisabledSubject(): void
{
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(false);
$requester = self::userWithId(2);
$requester->setRoles([User::ROLE_TEAMLEAD]);
self::assertFalse($sut->checkUserAccess($subject, $requester));
}
public function testCheckUserAccessDeniesSystemAccountForNonSystemRequester(): void
{
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(true);
$subject->setSystemAccount(true);
$requester = self::userWithId(2);
$requester->setRoles([User::ROLE_TEAMLEAD]);
self::assertFalse($sut->checkUserAccess($subject, $requester));
}
public function testCheckUserAccessGrantsTeamleadOfUser(): void
{
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(true);
$requester = self::userWithId(2);
$team = new Team('Support');
$team->addUser($subject);
$team->addTeamlead($requester);
self::assertTrue($requester->isTeamleadOfUser($subject));
self::assertTrue($sut->checkUserAccess($subject, $requester));
}
public function testCheckUserAccessGrantsAdminFallbackForRegularUserWithoutTeam(): void
{
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(true);
$requester = self::userWithId(2);
$requester->setRoles([User::ROLE_ADMIN]);
self::assertTrue($subject->isRegularUserOnly());
self::assertSame([], $subject->getTeams());
self::assertTrue($sut->checkUserAccess($subject, $requester));
}
public function testCheckUserAccessGrantsTeamleadFallbackForRegularUserWithoutTeam(): void
{
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(true);
$requester = self::userWithId(2);
$requester->setRoles([User::ROLE_TEAMLEAD]);
self::assertTrue($subject->isRegularUserOnly());
self::assertSame([], $subject->getTeams());
self::assertTrue($sut->checkUserAccess($subject, $requester));
}
public function testCheckUserAccessDeniesFallbackForRegularUserWithTeam(): void
{
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(true);
(new Team('Support'))->addUser($subject);
$requester = self::userWithId(2);
$requester->setRoles([User::ROLE_ADMIN]);
self::assertTrue($subject->isRegularUserOnly());
self::assertNotSame([], $subject->getTeams());
self::assertFalse($sut->checkUserAccess($subject, $requester));
}
public function testCheckUserAccessDeniesFallbackForNonRegularUserWithoutTeam(): void
{
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(true);
$subject->addRole(User::ROLE_TEAMLEAD);
$requester = self::userWithId(2);
$requester->setRoles([User::ROLE_ADMIN]);
self::assertFalse($subject->isRegularUserOnly());
self::assertSame([], $subject->getTeams());
self::assertFalse($sut->checkUserAccess($subject, $requester));
}
public function testCheckUserAccessDeniesSystemRequesterWithoutMatchingAccessPath(): void
{
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(true);
$subject->setSystemAccount(true);
$requester = self::userWithId(2);
$requester->setSystemAccount(true);
self::assertFalse($sut->checkUserAccess($subject, $requester));
}
private static function userWithId(int $id): User
{
$user = new User();

View File

@@ -43,7 +43,9 @@ class LocaleFormatExtensionsTest extends TestCase
protected function tearDown(): void
{
date_default_timezone_set($this->oldTimezone);
if ($this->oldTimezone !== null) {
date_default_timezone_set($this->oldTimezone);
}
$this->oldTimezone = null;
}

View File

@@ -15,14 +15,16 @@ use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
use Twig\Error\RuntimeError;
#[CoversClass(EncoreExtension::class)]
class EncoreExtensionTest extends TestCase
{
protected function getSut(array $files = []): EncoreExtension
protected function getSut(array $files = [], bool $expectsReset = true): EncoreExtension
{
$entryLookup = $this->createMock(EntrypointLookupInterface::class);
$entryLookup->expects($this->any())->method('getCssFiles')->willReturn($files);
$entryLookup->expects($expectsReset ? $this->once() : $this->never())->method('reset');
$container = new Container(new ParameterBag([]));
$container->set(EntrypointLookupInterface::class, $entryLookup);
@@ -41,6 +43,32 @@ class EncoreExtensionTest extends TestCase
$css = 'body { margin: 0; }p
{
color: red; font-style: italic; }';
self::assertEquals($css, $sut->getEncoreEntryCssSource('blub'));
self::assertEquals($css, $sut->getEncoreEntryCssSource('invoice'));
}
public function testGetEncoreEntryCssSourceIgnoresNonCssFiles(): void
{
$sut = $this->getSut(['test.css', 'test.js', 'test1.css', 'build/app.css.map']);
$css = 'body { margin: 0; }p
{
color: red; font-style: italic; }';
self::assertEquals($css, $sut->getEncoreEntryCssSource('invoice-pdf'));
}
public function testGetEncoreEntryCssSourceIgnoresDirectoryTraversalPaths(): void
{
$sut = $this->getSut(['../composer.json', 'test.css', 'foo/../test1.css', '../ContextTest.php']);
self::assertSame('body { margin: 0; }', $sut->getEncoreEntryCssSource('export-pdf'));
}
public function testGetEncoreEntryCssSourceRejectsUnknownPackage(): void
{
$this->expectException(RuntimeError::class);
$this->expectExceptionMessage('Unknown CSS package requested: blub');
$sut = $this->getSut([], false);
$sut->getEncoreEntryCssSource('blub');
}
}

View File

@@ -0,0 +1,2 @@
function foo(message) { alert(message); };
foo('bar');

View File

@@ -0,0 +1,120 @@
<?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\Utils;
use App\Configuration\LocaleService;
use App\Entity\Timesheet;
use App\Utils\LocaleFormatter;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(LocaleFormatter::class)]
class LocaleFormatterTest extends TestCase
{
private ?string $oldTimezone = null;
protected function setUp(): void
{
$this->oldTimezone = date_default_timezone_get();
date_default_timezone_set('Europe/Vienna');
}
protected function tearDown(): void
{
if ($this->oldTimezone !== null) {
date_default_timezone_set($this->oldTimezone);
}
$this->oldTimezone = null;
}
public function testDurationFormattingWithTimesheet(): void
{
$sut = $this->getSut('en');
$timesheet = (new Timesheet())
->setBegin(new \DateTime('2020-07-09 08:00:00', new \DateTimeZone('Europe/Vienna')))
->setEnd(new \DateTime('2020-07-09 10:37:17', new \DateTimeZone('Europe/Vienna')))
->setDuration(9437);
self::assertSame('2:37', $sut->duration($timesheet));
self::assertSame('2.62', $sut->duration($timesheet, true));
self::assertSame('2.62', $sut->durationDecimal($timesheet));
}
public function testDurationDecimalWorksAfterAmountFormatting(): void
{
$sut = $this->getSut('de');
self::assertSame('1.234,5', $sut->amount(1234.5));
self::assertSame('2,62', $sut->durationDecimal(9437));
}
public function testAmountAndMoneyFormatting(): void
{
$sut = $this->getSut('en');
self::assertSame('0', $sut->amount(null));
self::assertSame('2,345.009', $sut->amount(2345.009));
self::assertSame('€2,345.01', $sut->money(2345.009, 'EUR'));
self::assertSame('2,345.01', $sut->money(2345.009, 'EUR', false));
}
public function testCurrencyFormattingFallsBackToInput(): void
{
$sut = $this->getSut('de');
self::assertSame('', $sut->currency(null));
self::assertSame('€', $sut->currency('eur'));
self::assertSame('INVALID', $sut->currency('INVALID'));
}
public function testDateAndTimeFormatting(): void
{
$sut = $this->getSut('de');
$date = new \DateTimeImmutable('1980-12-14 13:27:55', new \DateTimeZone('Europe/Vienna'));
self::assertSame('14.12.1980', $sut->dateShort($date));
self::assertSame('14.12.1980 13:27:55', $sut->dateTime($date));
self::assertSame('1980-12-14T13:27:55+01:00', $sut->dateFormat($date, 'c'));
self::assertSame('13:27:55', $sut->time($date));
}
public function testInvalidDateInputReturnsNull(): void
{
$sut = $this->getSut('en');
self::assertNull($sut->dateShort('not-a-date'));
self::assertNull($sut->dateTime('not-a-date'));
self::assertNull($sut->dateFormat('not-a-date', 'c'));
self::assertNull($sut->time('not-a-date'));
}
public function testLocalizedNames(): void
{
$sut = $this->getSut('en');
$date = new \DateTimeImmutable('2020-07-09 12:00:00', new \DateTimeZone('Europe/Vienna'));
self::assertSame('July', $sut->monthName($date));
self::assertSame('July 2020', $sut->monthName($date, true));
self::assertSame('Q3', $sut->quarterName($date));
self::assertSame('Q3 2020', $sut->quarterName($date, true));
self::assertSame('Thursday', $sut->dayName($date));
self::assertSame('Thu', $sut->dayName($date, true));
}
private function getSut(string $locale): LocaleFormatter
{
return new LocaleFormatter(new LocaleService([
'de' => array_merge(LocaleService::DEFAULT_SETTINGS, ['date' => 'dd.MM.Y', 'time' => 'HH:mm:ss']),
'en' => array_merge(LocaleService::DEFAULT_SETTINGS, ['date' => 'Y-MM-dd', 'time' => 'HH:mm']),
]), $locale);
}
}

View File

@@ -0,0 +1,242 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Validator\Constraints;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Team;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Security\RolePermissionManager;
use App\User\PermissionService;
use App\Validator\Constraints\TimesheetTeamAccess;
use App\Validator\Constraints\TimesheetTeamAccessValidator;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\UnitOfWork;
use Doctrine\Persistence\ManagerRegistry;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @extends ConstraintValidatorTestCase<TimesheetTeamAccessValidator>
*/
#[CoversClass(TimesheetTeamAccess::class)]
#[CoversClass(TimesheetTeamAccessValidator::class)]
class TimesheetTeamAccessValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): TimesheetTeamAccessValidator
{
return $this->createMyValidator();
}
/**
* @param array<string, mixed> $originalData
*/
protected function createMyValidator(
array $originalData = [],
?User $user = null
): TimesheetTeamAccessValidator
{
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn($user ?? new User());
$permissionService = $this->createMock(PermissionService::class);
$permissionService->method('getPermissions')->willReturn([]);
$permissionManager = new RolePermissionManager($permissionService, [], []);
$unitOfWork = $this->createMock(UnitOfWork::class);
$unitOfWork->method('getOriginalEntityData')->willReturn($originalData);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->method('getUnitOfWork')->willReturn($unitOfWork);
$registry = $this->createMock(ManagerRegistry::class);
$registry->method('getManagerForClass')->willReturn($entityManager);
return new TimesheetTeamAccessValidator($security, $permissionManager, $registry);
}
public function testConstraintIsInvalid(): void
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new Timesheet(), new NotBlank());
}
public function testInvalidValueThrowsException(): void
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new NotBlank(), new TimesheetTeamAccess(['message' => 'myMessage']));
}
public function testTriggersForNewTimesheetWithInaccessibleProject(): void
{
$this->validator = $this->createMyValidator();
$this->validator->initialize($this->context);
$timesheet = new Timesheet();
$timesheet->setProject($this->createRestrictedProject('restricted'));
$this->validator->validate($timesheet, new TimesheetTeamAccess());
$this->buildViolation('You are not allowed to use this project.')
->atPath('property.path.project')
->setCode(TimesheetTeamAccess::PROJECT_ACCESS_ERROR)
->assertRaised();
}
public function testDoesNotReadOriginalDataForNewTimesheet(): void
{
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn(new User());
$permissionService = $this->createMock(PermissionService::class);
$permissionService->method('getPermissions')->willReturn([]);
$permissionManager = new RolePermissionManager($permissionService, [], []);
$registry = $this->createMock(ManagerRegistry::class);
$registry->expects(self::never())->method('getManagerForClass');
$this->validator = new TimesheetTeamAccessValidator($security, $permissionManager, $registry);
$this->validator->initialize($this->context);
$timesheet = new Timesheet();
$timesheet->setProject($this->createRestrictedProject('new-timesheet'));
$this->validator->validate($timesheet, new TimesheetTeamAccess());
$this->buildViolation('You are not allowed to use this project.')
->atPath('property.path.project')
->setCode(TimesheetTeamAccess::PROJECT_ACCESS_ERROR)
->assertRaised();
}
public function testDoesNotTriggerForExistingTimesheetWithUnchangedProject(): void
{
$originalProject = $this->createRestrictedProject('restricted');
$this->validator = $this->createMyValidator(['project' => $originalProject]);
$this->validator->initialize($this->context);
$timesheet = $this->createPersistedTimesheet();
$timesheet->setProject($originalProject);
$this->validator->validate($timesheet, new TimesheetTeamAccess());
$this->assertNoViolation();
}
public function testTriggersForExistingTimesheetWithChangedProject(): void
{
$this->validator = $this->createMyValidator(['project' => $this->createProject('old')]);
$this->validator->initialize($this->context);
$timesheet = $this->createPersistedTimesheet();
$timesheet->setProject($this->createRestrictedProject('new'));
$this->validator->validate($timesheet, new TimesheetTeamAccess());
$this->buildViolation('You are not allowed to use this project.')
->atPath('property.path.project')
->setCode(TimesheetTeamAccess::PROJECT_ACCESS_ERROR)
->assertRaised();
}
public function testTriggersForExistingTimesheetWithChangedActivity(): void
{
$this->validator = $this->createMyValidator(['activity' => $this->createActivity('old')]);
$this->validator->initialize($this->context);
$timesheet = $this->createPersistedTimesheet();
$timesheet->setActivity($this->createRestrictedActivity('new'));
$this->validator->validate($timesheet, new TimesheetTeamAccess());
$this->buildViolation('You are not allowed to use this activity.')
->atPath('property.path.activity')
->setCode(TimesheetTeamAccess::ACTIVITY_ACCESS_ERROR)
->assertRaised();
}
public function testDoesNotTriggerForSuperAdmin(): void
{
$user = new User();
$user->setRoles([User::ROLE_SUPER_ADMIN]);
$this->validator = $this->createMyValidator([], $user);
$this->validator->initialize($this->context);
$timesheet = new Timesheet();
$timesheet
->setProject($this->createRestrictedProject('restricted'))
->setActivity($this->createRestrictedActivity('restricted'))
;
$this->validator->validate($timesheet, new TimesheetTeamAccess());
$this->assertNoViolation();
}
public function testGetTargets(): void
{
$constraint = new TimesheetTeamAccess();
self::assertEquals('class', $constraint->getTargets());
}
private function createPersistedTimesheet(): Timesheet
{
$timesheet = new Timesheet();
$reflection = new \ReflectionClass($timesheet);
$property = $reflection->getProperty('id');
$property->setAccessible(true);
$property->setValue($timesheet, 1);
$property->setAccessible(false);
return $timesheet;
}
private function createProject(string $name): Project
{
$project = new Project();
$project->setName($name);
$project->setCustomer(new Customer('customer-' . $name));
return $project;
}
private function createActivity(string $name): Activity
{
$activity = new Activity();
$activity->setName($name);
return $activity;
}
private function createRestrictedProject(string $name): Project
{
$project = $this->createProject($name);
$project->getCustomer()?->addTeam(new Team('customer-team-' . $name));
return $project;
}
private function createRestrictedActivity(string $name): Activity
{
$activity = $this->createActivity($name);
$activity->addTeam(new Team('activity-team-' . $name));
return $activity;
}
}

View File

@@ -16,6 +16,7 @@ use App\Entity\Project;
use App\Entity\Team;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Form\Model\MultiUserTimesheet;
use App\Tests\Mocks\SystemConfigurationFactory;
use App\Timesheet\LockdownService;
use App\Voter\TimesheetVoter;
@@ -169,6 +170,62 @@ class TimesheetVoterTest extends AbstractVoterTestCase
$this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED);
}
public function testIsOwnerGrantedForOwnTimesheet(): void
{
// is_owner bypasses the RolePermissionManager entirely: a user with no
// role/permissions at all must still be recognised as the owner.
$owner = self::getUser(1, 'unknown');
$timesheet = self::getTimesheet($owner);
$this->assertVote($owner, $timesheet, 'is_owner', VoterInterface::ACCESS_GRANTED);
}
public function testIsOwnerDeniedForOtherUsersTimesheet(): void
{
$owner = self::getUser(1, User::ROLE_USER);
$other = self::getUser(2, User::ROLE_SUPER_ADMIN);
$timesheet = self::getTimesheet($owner);
// even a super admin is not the *owner* of someone else's timesheet
$this->assertVote($other, $timesheet, 'is_owner', VoterInterface::ACCESS_DENIED);
}
public function testIsOwnerUsesObjectIdentityNotId(): void
{
// The is_owner branch compares with strict identity ($user === $subject->getUser()),
// not by id like the permission-based branches. Two distinct User instances that
// share the same id are therefore NOT considered the same owner.
$tokenUser = self::getUser(1, User::ROLE_USER);
$timesheetUser = self::getUser(1, User::ROLE_USER);
$timesheet = self::getTimesheet($timesheetUser);
$this->assertVote($tokenUser, $timesheet, 'is_owner', VoterInterface::ACCESS_DENIED);
}
public function testIsOwnerDeniedWhenTimesheetHasNoUser(): void
{
$user = self::getUser(1, User::ROLE_USER);
$timesheet = new Timesheet();
$this->assertVote($user, $timesheet, 'is_owner', VoterInterface::ACCESS_DENIED);
}
public function testIsOwnerDeniedForMultiUserTimesheetEvenWhenSameUser(): void
{
// A MultiUserTimesheet is explicitly excluded from being "owned",
// regardless of the assigned user.
$owner = self::getUser(1, User::ROLE_USER);
$timesheet = new MultiUserTimesheet();
$timesheet->setUser($owner);
$this->assertVote($owner, $timesheet, 'is_owner', VoterInterface::ACCESS_DENIED);
}
private static function getTimesheet($user): Timesheet
{
$activity = new Activity();

View File

@@ -20,12 +20,12 @@ parameters:
objectManagerLoader: %rootDir%/../../../tests/phpstan-doctrine.php
ignoreErrors:
- identifier: missingType.iterableValue
-
message: "#^Call to static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertInstanceOf\\(\\) with '(.*)' and (.*) will always evaluate to true\\.$#"
-
message: "#^PHPDoc tag @var with type App\\\\(.*) is not subtype of native type PHPUnit\\\\Framework\\\\MockObject\\\\MockObject\\.$#"
- message: "#^Call to static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertInstanceOf\\(\\) with '(.*)' and (.*) will always evaluate to true\\.$#"
- message: "#^PHPDoc tag @var with type App\\\\(.*) is not subtype of native type PHPUnit\\\\Framework\\\\MockObject\\\\MockObject\\.$#"
- message: '#^Call to deprecated method getApiToken\(\) of class App\\Entity\\User#'
- message: '#^Call to deprecated method setApiToken\(\) of class App\\Entity\\User#'
- message: '#^Call to deprecated method getPlainApiToken\(\) of class App\\Entity\\User#'
- message: '#^Call to deprecated method setPlainApiToken\(\) of class App\\Entity\\User#'
-
identifier: classConstant.deprecatedClass
@@ -47,6 +47,11 @@ parameters:
count: 13
path: API/Authentication/TokenAuthenticatorTest.php
-
identifier: method.deprecated
count: 2
path: API/Authentication/TokenAuthenticatorTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\APIControllerBaseTestCase\\:\\:assertApiException\\(\\) has parameter \\$expectedErrors with no value type specified in iterable type array\\.$#"
count: 1
@@ -581,16 +586,6 @@ parameters:
count: 1
path: Controller/CustomerControllerTest.php
-
message: "#^Parameter \\#2 \\$haystack of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|null given\\.$#"
count: 2
path: Controller/CustomerControllerTest.php
-
message: "#^Parameter \\#2 \\$url of method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:request\\(\\) expects string, string\\|null given\\.$#"
count: 2
path: Controller/CustomerControllerTest.php
-
message: "#^Parameter \\#1 \\$project of method App\\\\Entity\\\\Team\\:\\:addProject\\(\\) expects App\\\\Entity\\\\Project, App\\\\Entity\\\\Project\\|null given\\.$#"
count: 1
@@ -781,16 +776,6 @@ parameters:
count: 1
path: Controller/ProjectControllerTest.php
-
message: "#^Parameter \\#2 \\$haystack of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|null given\\.$#"
count: 2
path: Controller/ProjectControllerTest.php
-
message: "#^Parameter \\#2 \\$url of method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:request\\(\\) expects string, string\\|null given\\.$#"
count: 2
path: Controller/ProjectControllerTest.php
-
message: "#^Cannot access property \\$childNodes on DOMNode\\|null\\.$#"
count: 2
@@ -2081,11 +2066,6 @@ parameters:
count: 5
path: Twig/LocaleFormatExtensionsTest.php
-
message: "#^Parameter \\#1 \\$timezoneId of function date_default_timezone_set expects string, string\\|null given\\.$#"
count: 1
path: Twig/LocaleFormatExtensionsTest.php
-
message: "#^Property App\\\\Tests\\\\Twig\\\\LocaleFormatExtensionsTest\\:\\:\\$localeDe type has no value type specified in iterable type array\\.$#"
count: 1