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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user