Release 2.58 (#5952)

* bump version
* fix formatting locale reset after embedded controller sub-requests (#5944)
* fix GHSA-c6w6-57jj-62vh
* fix GHSA-m492-gv72-xvxj
* fix GHSA-jr9p-4h4j-6c58
* make sure to only use JS logic to call API endpoints
* fixes GHSA-r8vr-m544-qh4h
* make sure to only use JS logic to call API endpoints
* fix GHSA-rw46-qg69-vg6h
* fix GHSA-pj8j-p4g4-4vw8 - prevent kimai from rendering images via markdown
* fix GHSA-pj8j-p4g4-4vw8 - use a safe network client to prevent SSRF via images
* fix GHSA-xv4r-4885-gwpg
* fix GHSA-pgcc-vfmc-7cw5 - move GET routes to API with POST method to prevent CSRF
* fix tooltip survives page reload
* updated wizard images
* split wizard and password reset subscriber into two classes
* relax upper php limit
* added zizmor workflow scans and apply findings
* user permissions <name>_other_profile  now respect teams
* move all linting steps to new job
* updated docker image version names
* use .env.local for storing APP_SECRET
* improve build order and use given tag as ref for checkout, not default main branch
* improved APP_SECRET handling, see entrypoint.sh
* use local code for building the image for more flexibility, added dockerignore
This commit is contained in:
Kevin Papst
2026-05-25 15:39:47 +02:00
committed by GitHub
parent 8d245ae223
commit 31a8f887a5
85 changed files with 2737 additions and 472 deletions

View File

@@ -559,4 +559,49 @@ class ActivityControllerTest extends APIControllerBaseTestCase
'message' => 'Not Found'
]);
}
public function testPostDefaultTeamAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/api/activities/1/team', 'POST');
self::assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TeamEntity', $result);
self::assertIsNumeric($result['id']);
$teamId = $result['id'];
self::assertIsArray($result['members']);
self::assertCount(1, $result['members']);
self::assertIsArray($result['members'][0]);
self::assertArrayHasKey('teamlead', $result['members'][0]);
self::assertTrue($result['members'][0]['teamlead']);
// idempotent
$this->request($client, '/api/activities/1/team', 'POST');
self::assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertSame($teamId, $result['id']);
self::assertIsArray($result['members']);
self::assertCount(1, $result['members']);
}
public function testPostDefaultTeamActionIsSecure(): void
{
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/activities/1/team', 'POST');
}
public function testPostDefaultTeamActionNotFound(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertEntityNotFoundForPost($client, '/api/activities/' . PHP_INT_MAX . '/team');
}
}

View File

@@ -66,6 +66,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
'/api/activities/{id}/meta',
'/api/activities/{id}/rates',
'/api/activities/{id}/rates/{rateId}',
'/api/activities/{id}/team',
'/api/config/timesheet',
'/api/config/colors',
'/api/customers',
@@ -76,6 +77,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
'/api/customers/{id}/comments',
'/api/customers/{id}/comments/{comment}/pin',
'/api/customers/{id}/comments/{comment}',
'/api/customers/{id}/team',
'/api/export/{id}',
'/api/invoices',
'/api/invoices/{id}',
@@ -89,6 +91,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
'/api/projects/{id}/comments',
'/api/projects/{id}/comments/{comment}/pin',
'/api/projects/{id}/comments/{comment}',
'/api/projects/{id}/team',
'/api/ping',
'/api/version',
'/api/plugins',

View File

@@ -874,4 +874,50 @@ class CustomerControllerTest extends APIControllerBaseTestCase
self::assertNull($this->getEntityManager()->getRepository(CustomerComment::class)->find($commentId));
}
public function testPostDefaultTeamAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/api/customers/1/team', 'POST');
self::assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TeamEntity', $result);
self::assertIsNumeric($result['id']);
$teamId = $result['id'];
// verify customer is bound and current user is teamlead
self::assertIsArray($result['members']);
self::assertCount(1, $result['members']);
self::assertIsArray($result['members'][0]);
self::assertArrayHasKey('teamlead', $result['members'][0]);
self::assertTrue($result['members'][0]['teamlead']);
// idempotent: calling again returns the same team without duplicate bindings or members
$this->request($client, '/api/customers/1/team', 'POST');
self::assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertSame($teamId, $result['id']);
self::assertIsArray($result['members']);
self::assertCount(1, $result['members']);
}
public function testPostDefaultTeamActionIsSecure(): void
{
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/customers/1/team', 'POST');
}
public function testPostDefaultTeamActionNotFound(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertEntityNotFoundForPost($client, '/api/customers/' . PHP_INT_MAX . '/team');
}
}

View File

@@ -983,4 +983,49 @@ class ProjectControllerTest extends APIControllerBaseTestCase
self::assertNull($this->getEntityManager()->getRepository(ProjectComment::class)->find($commentId));
}
public function testPostDefaultTeamAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/api/projects/1/team', 'POST');
self::assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TeamEntity', $result);
self::assertIsNumeric($result['id']);
$teamId = $result['id'];
self::assertIsArray($result['members']);
self::assertCount(1, $result['members']);
self::assertIsArray($result['members'][0]);
self::assertArrayHasKey('teamlead', $result['members'][0]);
self::assertTrue($result['members'][0]['teamlead']);
// idempotent
$this->request($client, '/api/projects/1/team', 'POST');
self::assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertSame($teamId, $result['id']);
self::assertIsArray($result['members']);
self::assertCount(1, $result['members']);
}
public function testPostDefaultTeamActionIsSecure(): void
{
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/projects/1/team', 'POST');
}
public function testPostDefaultTeamActionNotFound(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertEntityNotFoundForPost($client, '/api/projects/' . PHP_INT_MAX . '/team');
}
}

View File

@@ -9,9 +9,16 @@
namespace App\Tests\API;
use App\DataFixtures\UserFixtures;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Role;
use App\Entity\RolePermission;
use App\Entity\Team;
use App\Entity\User;
use App\Tests\DataFixtures\TeamFixtures;
use App\User\PermissionService;
use Doctrine\ORM\EntityManager;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
@@ -778,4 +785,334 @@ class TeamControllerTest extends APIControllerBaseTestCase
// cannot remove activity
$this->assertBadRequest($client, '/api/teams/' . $result['id'] . '/activities/1', 'DELETE');
}
/**
* Sets up tony_teamlead so that he has the `edit_team` permission via a
* dedicated test role, and makes him the teamlead of a fresh team.
*
* This simulates an installation that lets teamleads manage their own
* teams. The permission is routed through PermissionService so the shared
* cache is invalidated and the request kernel sees the new permission.
*
* @return Team the team the attacker is teamlead of
*/
private function prepareAttackerTeamleadWithEditTeam(string $suffix): Team
{
$em = $this->getEntityManager();
$roleName = 'TEST_EDIT_TEAM_' . $suffix;
$role = (new Role())->setName($roleName);
$permission = (new RolePermission())->setRole($role)->setPermission('edit_team')->setAllowed(true);
$em->persist($role);
$p = self::getContainer()->get(PermissionService::class);
self::assertInstanceOf(PermissionService::class, $p);
$p->saveRolePermission($permission);
$attacker = $this->getUserByName(UserFixtures::USERNAME_TEAMLEAD);
$attacker->addRole($roleName);
$em->persist($attacker);
$attackerTeam = new Team('GHSA-xv4r attacker team ' . $suffix);
$attackerTeam->addTeamlead($attacker);
$em->persist($attackerTeam);
$em->flush();
return $attackerTeam;
}
/**
* Regression test for GHSA-xv4r-4885-gwpg.
*
* A teamlead with edit_team permission must not be able to add a user
* that falls outside their authorized management scope by calling the
* member-assignment API directly. The frontend hides those users; the
* backend has to enforce the same boundary.
*/
public function testPostMemberActionDeniesUserOutsideTeamleadScope(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$attackerTeam = $this->prepareAttackerTeamleadWithEditTeam('GHSA_XV4R_MEMBER');
// target user is in a separate team that the attacker has no role in,
// and the target is not a "regular-user-only without any teams" (which
// would otherwise be visible to any teamlead).
$target = $this->getUserByName(UserFixtures::USERNAME_USER);
$isolatedTeam = new Team('GHSA-xv4r isolated team');
$isolatedTeam->addUser($target);
$isolatedTeam->addTeamlead($this->getUserByRole(User::ROLE_SUPER_ADMIN));
$em->persist($isolatedTeam);
$em->flush();
$teamId = $attackerTeam->getId();
$targetId = $target->getId();
self::assertIsInt($teamId);
self::assertIsInt($targetId);
$this->request($client, '/api/teams/' . $teamId . '/members/' . $targetId, 'POST');
$this->assertApiResponseAccessDenied($client->getResponse());
// verify the relation was NOT persisted
$em->clear();
$reloaded = $em->getRepository(Team::class)->find($teamId);
self::assertInstanceOf(Team::class, $reloaded);
self::assertFalse($reloaded->hasUser($target));
}
/**
* Regression test for GHSA-xv4r-4885-gwpg.
*
* The teamlead must not be able to attach an activity that they cannot
* view in the first place, even when they may edit the team.
*/
public function testPostActivityActionDeniesActivityOutsideTeamleadScope(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$attackerTeam = $this->prepareAttackerTeamleadWithEditTeam('GHSA_XV4R_ACTIVITY');
// activity is created without any team relation that the attacker is part of
$customer = new Customer('GHSA-xv4r activity customer');
$customer->setCountry('DE');
$customer->setTimezone('Europe/Berlin');
$em->persist($customer);
$project = new Project();
$project->setName('GHSA-xv4r activity project');
$project->setCustomer($customer);
$em->persist($project);
$activity = new Activity();
$activity->setName('GHSA-xv4r out-of-scope activity');
$activity->setProject($project);
$em->persist($activity);
$em->flush();
$teamId = $attackerTeam->getId();
$activityId = $activity->getId();
self::assertIsInt($teamId);
self::assertIsInt($activityId);
$this->request($client, '/api/teams/' . $teamId . '/activities/' . $activityId, 'POST');
$this->assertApiResponseAccessDenied($client->getResponse());
$em->clear();
$reloaded = $em->getRepository(Team::class)->find($teamId);
self::assertInstanceOf(Team::class, $reloaded);
$reloadedActivity = $em->getRepository(Activity::class)->find($activityId);
self::assertInstanceOf(Activity::class, $reloadedActivity);
self::assertFalse($reloaded->hasActivity($reloadedActivity));
}
/**
* Regression test for GHSA-xv4r-4885-gwpg (postCustomerAction variant).
*
* A teamlead with edit_team permission must not be able to grant their
* team access to a customer that they cannot view themselves. The bug
* pattern is identical to the postActivityAction variant.
*/
public function testPostCustomerActionDeniesCustomerOutsideTeamleadScope(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$attackerTeam = $this->prepareAttackerTeamleadWithEditTeam('GHSA_XV4R_CUSTOMER');
// customer has no team relation to the attacker -> attacker has no view permission on it
$customer = new Customer('GHSA-xv4r out-of-scope customer');
$customer->setCountry('DE');
$customer->setTimezone('Europe/Berlin');
$em->persist($customer);
$em->flush();
$teamId = $attackerTeam->getId();
$customerId = $customer->getId();
self::assertIsInt($teamId);
self::assertIsInt($customerId);
$this->request($client, '/api/teams/' . $teamId . '/customers/' . $customerId, 'POST');
$this->assertApiResponseAccessDenied($client->getResponse());
$em->clear();
$reloaded = $em->getRepository(Team::class)->find($teamId);
self::assertInstanceOf(Team::class, $reloaded);
$reloadedCustomer = $em->getRepository(Customer::class)->find($customerId);
self::assertInstanceOf(Customer::class, $reloadedCustomer);
self::assertFalse($reloaded->hasCustomer($reloadedCustomer));
}
/**
* Regression test for GHSA-xv4r-4885-gwpg (postProjectAction variant).
*
* A teamlead with edit_team permission must not be able to grant their
* team access to a project that they cannot view themselves.
*/
public function testPostProjectActionDeniesProjectOutsideTeamleadScope(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$attackerTeam = $this->prepareAttackerTeamleadWithEditTeam('GHSA_XV4R_PROJECT');
$customer = new Customer('GHSA-xv4r project customer');
$customer->setCountry('DE');
$customer->setTimezone('Europe/Berlin');
$em->persist($customer);
$project = new Project();
$project->setName('GHSA-xv4r out-of-scope project');
$project->setCustomer($customer);
$em->persist($project);
$em->flush();
$teamId = $attackerTeam->getId();
$projectId = $project->getId();
self::assertIsInt($teamId);
self::assertIsInt($projectId);
$this->request($client, '/api/teams/' . $teamId . '/projects/' . $projectId, 'POST');
$this->assertApiResponseAccessDenied($client->getResponse());
$em->clear();
$reloaded = $em->getRepository(Team::class)->find($teamId);
self::assertInstanceOf(Team::class, $reloaded);
$reloadedProject = $em->getRepository(Project::class)->find($projectId);
self::assertInstanceOf(Project::class, $reloadedProject);
self::assertFalse($reloaded->hasProject($reloadedProject));
}
/**
* Regression test for GHSA-xv4r-4885-gwpg (patchAction variant).
*
* The PATCH /api/teams/{id} endpoint takes a `members` array and replaces
* the team's membership. A teamlead with edit_team permission must not be
* able to attach an out-of-scope user this way.
*/
public function testPatchActionDeniesAddingOutOfScopeMember(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$attackerTeam = $this->prepareAttackerTeamleadWithEditTeam('GHSA_XV4R_PATCH');
$attacker = $this->getUserByName(UserFixtures::USERNAME_TEAMLEAD);
$attackerId = $attacker->getId();
self::assertIsInt($attackerId);
// target user kept out of attacker's reach
$target = $this->getUserByName(UserFixtures::USERNAME_USER);
$isolatedTeam = new Team('GHSA-xv4r isolated team patch');
$isolatedTeam->addUser($target);
$isolatedTeam->addTeamlead($this->getUserByRole(User::ROLE_SUPER_ADMIN));
$em->persist($isolatedTeam);
$em->flush();
$teamId = $attackerTeam->getId();
$targetId = $target->getId();
self::assertIsInt($teamId);
self::assertIsInt($targetId);
$payload = [
'name' => 'GHSA-xv4r patch team',
'members' => [
['user' => $attackerId, 'teamlead' => true],
['user' => $targetId, 'teamlead' => false],
],
];
$this->request($client, '/api/teams/' . $teamId, 'PATCH', [], json_encode($payload));
$response = $client->getResponse();
// either a hard 403 or a validation rejection of the members field is acceptable;
// any 2xx that ends with the target attached to the team is the security failure.
self::assertFalse(
$response->isSuccessful() && str_contains((string) $response->getContent(), '"id"'),
'PATCH /api/teams must not silently attach an out-of-scope user via the members array.'
);
$em->clear();
$reloaded = $em->getRepository(Team::class)->find($teamId);
self::assertInstanceOf(Team::class, $reloaded);
self::assertFalse(
$reloaded->hasUser($target),
'Out-of-scope user must not have been added to the team via PATCH.'
);
}
/**
* Regression test for GHSA-xv4r-4885-gwpg (postAction variant).
*
* The POST /api/teams endpoint accepts a `members` array. A user whose
* role grants `create_team` but not `view_all_data` must not be able to
* create a team with members they cannot manage. This covers the
* non-admin "team creator" role configuration.
*/
public function testPostActionDeniesCreatingTeamWithOutOfScopeMember(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
// grant create_team to a custom role and attach it to tony_teamlead
$roleName = 'TEST_CREATE_TEAM_GHSA_XV4R';
$role = (new Role())->setName($roleName);
$permission = (new RolePermission())->setRole($role)->setPermission('create_team')->setAllowed(true);
$em->persist($role);
$p = self::getContainer()->get(PermissionService::class);
self::assertInstanceOf(PermissionService::class, $p);
$p->saveRolePermission($permission);
$attacker = $this->getUserByName(UserFixtures::USERNAME_TEAMLEAD);
$attacker->addRole($roleName);
$em->persist($attacker);
$attackerId = $attacker->getId();
self::assertIsInt($attackerId);
// target user is unreachable for the attacker
$target = $this->getUserByName(UserFixtures::USERNAME_USER);
$isolatedTeam = new Team('GHSA-xv4r isolated team create');
$isolatedTeam->addUser($target);
$isolatedTeam->addTeamlead($this->getUserByRole(User::ROLE_SUPER_ADMIN));
$em->persist($isolatedTeam);
$em->flush();
$targetId = $target->getId();
self::assertIsInt($targetId);
$payload = [
'name' => 'GHSA-xv4r created team',
'members' => [
['user' => $attackerId, 'teamlead' => true],
['user' => $targetId, 'teamlead' => false],
],
];
$this->request($client, '/api/teams', 'POST', [], json_encode($payload));
$response = $client->getResponse();
$body = (string) $response->getContent();
// success body would contain the new id and the target as a member -> security failure
if ($response->isSuccessful()) {
$decoded = json_decode($body, true);
self::assertIsArray($decoded);
$memberIds = [];
if (\is_array($decoded['members'] ?? null)) {
foreach ($decoded['members'] as $entry) {
if (\is_array($entry) && \is_array($entry['user'] ?? null) && isset($entry['user']['id'])) {
$memberIds[] = $entry['user']['id'];
}
}
}
self::assertNotContains(
$targetId,
$memberIds,
'POST /api/teams must not silently accept an out-of-scope user in the members array.'
);
}
}
}

View File

@@ -1528,6 +1528,72 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$this->assertEntityNotFoundForPatch(User::ROLE_ADMIN, '/api/timesheets/11/duplicate', []);
}
// ------------------------------------------------------------------
// GHSA-c6w6-57jj-62vh — restart/duplicate after project access revocation.
//
// "restart" and "duplicate" derive a NEW timesheet from a historical
// entry the user still owns. Once the user's team access to the underlying
// project/activity is revoked, neither operation may create a new record
// under it. The data write itself is already blocked by
// TimesheetTeamAccessValidator (since 2.57); these tests additionally pin
// that the TimesheetVoter denies the request at the authorization layer —
// a clean 403, not an incidental 400 from downstream validation.
// ------------------------------------------------------------------
public function testRestartAndDuplicateDeniedAfterProjectAccessRevoked(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$em = $this->getEntityManager();
$owner = $this->getUserByRole(User::ROLE_USER);
// The customer is restricted to a team the user is NOT a member of:
// the user's access to this project/activity has been revoked, but
// their historical timesheet still references it.
$revokedTeam = new Team('GHSA-c6w6 team without access');
$em->persist($revokedTeam);
$timesheet = $this->persistRestrictedTimesheet($owner, [$revokedTeam], running: false);
$id = $timesheet->getId();
self::assertIsInt($id);
$before = $this->getEntityManager()->getRepository(Timesheet::class)->count([]);
// PATCH + GET .../restart
$this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse());
// PATCH .../duplicate
$this->request($client, '/api/timesheets/' . $id . '/duplicate', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse());
// No new record may have been persisted under the revoked project.
$after = $this->getEntityManager()->getRepository(Timesheet::class)->count([]);
self::assertSame($before, $after, 'restart/duplicate leaked through and created a new timesheet under the revoked project');
}
public function testRestartAndDuplicateAllowedWhenUserStillHasProjectAccess(): void
{
// Positive control: as long as the user still has team access to the
// project/activity, restart and duplicate keep working.
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$em = $this->getEntityManager();
$owner = $this->getUserByRole(User::ROLE_USER);
$team = new Team('GHSA-c6w6 team with access');
$team->addUser($owner);
$em->persist($team);
$timesheet = $this->persistRestrictedTimesheet($owner, [$team], running: false);
$id = $timesheet->getId();
self::assertIsInt($id);
$this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH');
self::assertTrue($client->getResponse()->isSuccessful(), 'restart must succeed while the user still has project access');
$this->request($client, '/api/timesheets/' . $id . '/duplicate', 'PATCH');
self::assertTrue($client->getResponse()->isSuccessful(), 'duplicate must succeed while the user still has project access');
}
public function testExportAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
@@ -1695,17 +1761,17 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$this->request($client, '/api/timesheets/' . $id, 'PATCH', [], $patch);
$this->assertApiResponseAccessDenied($client->getResponse());
// 3) PATCH /api/timesheets/{id}/stop and 4) GET .../stop
// 3) PATCH /api/timesheets/{id}/stop is access-denied; 4) GET .../stop is no longer routable
$this->request($client, '/api/timesheets/' . $id . '/stop', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse());
$this->request($client, '/api/timesheets/' . $id . '/stop', 'GET');
$this->assertApiResponseAccessDenied($client->getResponse());
self::assertEquals(Response::HTTP_METHOD_NOT_ALLOWED, $client->getResponse()->getStatusCode());
// 5) PATCH /api/timesheets/{id}/restart and 6) GET .../restart
// 5) PATCH /api/timesheets/{id}/restart is access-denied; 6) GET .../restart is no longer routable
$this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse());
$this->request($client, '/api/timesheets/' . $id . '/restart', 'GET');
$this->assertApiResponseAccessDenied($client->getResponse());
self::assertEquals(Response::HTTP_METHOD_NOT_ALLOWED, $client->getResponse()->getStatusCode());
// 7) PATCH /api/timesheets/{id}/duplicate
$this->request($client, '/api/timesheets/' . $id . '/duplicate', 'PATCH');
@@ -1931,7 +1997,7 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
$this->assertApiResponseAccessDenied($client->getResponse());
$this->request($client, '/api/timesheets/' . $id . '/stop', 'GET');
$this->assertApiResponseAccessDenied($client->getResponse());
self::assertEquals(Response::HTTP_METHOD_NOT_ALLOWED, $client->getResponse()->getStatusCode());
// Confirm side-effect-free: timesheet must still be running.
$em->clear();