Release 2.57 (#5929)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
127
tests/EventSubscriber/ThemeOptionsSubscriberTest.php
Normal file
127
tests/EventSubscriber/ThemeOptionsSubscriberTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -33,6 +33,7 @@ class InvoiceModelProjectHydratorTest extends TestCase
|
||||
public function assertModelStructure(array $model): void
|
||||
{
|
||||
$keys = [
|
||||
'project._counter',
|
||||
'project.id',
|
||||
'project.name',
|
||||
'project.comment',
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
29
tests/Invoice/InvoicePeriodTest.php
Normal file
29
tests/Invoice/InvoicePeriodTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
2
tests/Twig/public/test.js
Normal file
2
tests/Twig/public/test.js
Normal file
@@ -0,0 +1,2 @@
|
||||
function foo(message) { alert(message); };
|
||||
foo('bar');
|
||||
120
tests/Utils/LocaleFormatterTest.php
Normal file
120
tests/Utils/LocaleFormatterTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
242
tests/Validator/Constraints/TimesheetTeamAccessValidatorTest.php
Normal file
242
tests/Validator/Constraints/TimesheetTeamAccessValidatorTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user