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();

View File

@@ -350,22 +350,6 @@ class ActivityControllerTest extends AbstractControllerBaseTestCase
self::assertEquals(2, $activity->getTeams()->count());
}
public function testCreateDefaultTeamAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/activity/1/details');
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body');
self::assertStringContainsString('Visible to everyone, as no team was assigned yet.', $node->text());
$this->request($client, '/admin/activity/1/create_team');
$this->assertIsRedirect($client, $this->createUrl('/admin/activity/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-title');
self::assertStringContainsString('Only visible to the following teams and all admins.', $node->text());
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body table tbody tr');
self::assertEquals(1, $node->count());
}
public function testDeleteAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -216,22 +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 testCreateDefaultTeamAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/customer/1/details');
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body');
self::assertStringContainsString('Visible to everyone, as no team was assigned yet.', $node->text(null, true));
$this->request($client, '/admin/customer/1/create_team');
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-title');
self::assertStringContainsString('Only visible to the following teams and all admins.', $node->text(null, true));
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body table tbody tr');
self::assertEquals(1, $node->count());
}
public function testProjectsAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -265,6 +265,24 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/export/template-create');
}
public function testCreateTemplateIsSecureForTeamlead(): void
{
// GHSA-rw46-qg69-vg6h
$this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/export/template-create');
}
public function testEditTemplateIsSecureForTeamlead(): void
{
// GHSA-rw46-qg69-vg6h
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
/** @var ExportTemplate[] $templates */
$templates = $this->importFixture(new ExportTemplateFixtures());
$id = $templates[0]->getId();
$this->request($client, $this->createUrl('/export/template-edit/' . $id));
$this->assertAccessDenied($client);
}
public function testCreateTemplateAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -40,7 +40,8 @@ class FavoriteControllerTest extends AbstractControllerBaseTestCase
$content = $client->getResponse()->getContent();
self::assertNotFalse($content);
self::assertStringContainsString('<a class="api-link text-decoration-none text-body d-block" href="/api/timesheets/', $content);
self::assertStringContainsString('<a class="api-link text-decoration-none text-body d-block" href="#', $content);
self::assertStringContainsString('data-href="/api/timesheets/', $content);
self::assertStringContainsString('data-event="kimai.timesheetStart kimai.timesheetUpdate" data-method="PATCH" data-msg-error="timesheet', $content);
}

View File

@@ -331,22 +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 testCreateDefaultTeamAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/project/1/details');
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body');
self::assertStringContainsString('Visible to everyone, as no team was assigned yet.', $node->text(null, true));
$this->request($client, '/admin/project/1/create_team');
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-title');
self::assertStringContainsString('Only visible to the following teams and all admins.', $node->text(null, true));
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body table tbody tr');
self::assertEquals(1, $node->count());
}
public function testActivitiesAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -0,0 +1,216 @@
<?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\Entity\User;
use App\EventSubscriber\PasswordResetSubscriber;
use App\EventSubscriber\WizardSubscriber;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[CoversClass(PasswordResetSubscriber::class)]
class PasswordResetSubscriberTest extends TestCase
{
public function testGetSubscribedEvents(): void
{
self::assertEquals([KernelEvents::REQUEST => ['onKernelRequest', -20]], PasswordResetSubscriber::getSubscribedEvents());
}
public function testPasswordResetHasHigherPriorityThanWizardSubscriber(): void
{
self::assertGreaterThan(
WizardSubscriber::getSubscribedEvents()[KernelEvents::REQUEST][1],
PasswordResetSubscriber::getSubscribedEvents()[KernelEvents::REQUEST][1]
);
}
public function testOnKernelRequestIgnoresSubRequest(): void
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->never())->method('getToken');
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
$event = $this->createRequestEvent('/dashboard', false);
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresMissingToken(): void
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->never())->method('isGranted');
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn(null);
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
/**
* @return iterable<array{string}>
*/
public static function provideExcludedUris(): iterable
{
yield ['/api/timesheets'];
yield ['/register/new'];
yield ['/wizard/intro'];
}
#[DataProvider('provideExcludedUris')]
public function testOnKernelRequestIgnoresExcludedUris(string $uri): void
{
$token = $this->createMock(TokenInterface::class);
$token->expects($this->never())->method('getUser');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->never())->method('isGranted');
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
$event = $this->createRequestEvent($uri);
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresNonUserToken(): void
{
$token = $this->createMock(TokenInterface::class);
$token->expects($this->once())->method('getUser')->willReturn($this->createMock(UserInterface::class));
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->never())->method('isGranted');
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresUserWithoutFullAuthentication(): void
{
$user = new User();
$token = $this->createUserToken($user);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(false);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresUserWithoutPasswordReset(): void
{
$user = new User();
$user->setEnabled(true);
$token = $this->createUserToken($user);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->never())->method('generate');
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(true);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestRedirectsToPasswordWizard(): void
{
$user = new User();
$user->setEnabled(true);
$user->setRequiresPasswordReset();
$token = $this->createUserToken($user);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator
->expects($this->once())
->method('generate')
->with('wizard', ['wizard' => 'password'])
->willReturn('/wizard/password');
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(true);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new PasswordResetSubscriber($urlGenerator, $security, $storage);
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
$response = $event->getResponse();
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/wizard/password', $response->headers->get('Location'));
}
private function createUserToken(User $user): TokenInterface
{
$token = $this->createMock(TokenInterface::class);
$token->expects($this->once())->method('getUser')->willReturn($user);
return $token;
}
private function createRequestEvent(string $uri, bool $mainRequest = true): RequestEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
$request = Request::create($uri);
return new RequestEvent($kernel, $request, $mainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
}
}

View File

@@ -10,31 +10,152 @@
namespace App\Tests\EventSubscriber;
use App\Configuration\LocaleService;
use App\Entity\User;
use App\EventSubscriber\RedirectToLocaleSubscriber;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
#[CoversClass(RedirectToLocaleSubscriber::class)]
class RedirectToLocaleSubscriberTest extends TestCase
{
public function testConstruct(): void
public function testGetSubscribedEvents(): void
{
self::assertEquals([KernelEvents::REQUEST => ['onKernelRequest', 0]], RedirectToLocaleSubscriber::getSubscribedEvents());
}
public function testOnKernelRequestIgnoresNonHomepageRequest(): void
{
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->never())->method('getToken');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$sut = new RedirectToLocaleSubscriber($urlGenerator, new LocaleService(['de' => LocaleService::DEFAULT_SETTINGS, 'en' => LocaleService::DEFAULT_SETTINGS]));
$urlGenerator->expects($this->never())->method('generate');
self::assertEquals([KernelEvents::REQUEST => ['onKernelRequest']], RedirectToLocaleSubscriber::getSubscribedEvents());
$request = $this->createMock(Request::class);
$request->expects($this->once())->method('getPathInfo')->willReturn('/de');
$event = $this->createMock(RequestEvent::class);
$event->expects($this->once())->method('getRequest')->willReturn($request);
$event->expects($this->never())->method('setResponse');
$sut = new RedirectToLocaleSubscriber($urlGenerator, $this->createLocaleService(), $storage);
$event = $this->createRequestEvent('/de');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresHomepageWithSameHostReferer(): void
{
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->never())->method('getToken');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->never())->method('generate');
$sut = new RedirectToLocaleSubscriber($urlGenerator, $this->createLocaleService(), $storage);
$event = $this->createRequestEvent('/', ['referer' => 'https://www.kimai.test/de/dashboard']);
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestRedirectsAuthenticatedUserToLanguage(): void
{
$user = new User();
$user->setLanguage('fr');
$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);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator
->expects($this->once())
->method('generate')
->with('homepage', ['_locale' => 'fr'])
->willReturn('/fr');
$sut = new RedirectToLocaleSubscriber($urlGenerator, $this->createLocaleService(), $storage);
$event = $this->createRequestEvent('/');
$sut->onKernelRequest($event);
$response = $event->getResponse();
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/fr', $response->headers->get('Location'));
}
public function testOnKernelRequestRedirectsAnonymousUserToPreferredBrowserLanguage(): void
{
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn(null);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator
->expects($this->once())
->method('generate')
->with('homepage', ['_locale' => 'de'])
->willReturn('/de');
$sut = new RedirectToLocaleSubscriber($urlGenerator, $this->createLocaleService(), $storage);
$event = $this->createRequestEvent('/', ['Accept-Language' => 'de-DE,de;q=0.9,en;q=0.8']);
$sut->onKernelRequest($event);
$response = $event->getResponse();
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/de', $response->headers->get('Location'));
}
public function testOnKernelRequestFallsBackToDefaultLocaleForAnonymousUser(): void
{
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn(null);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator
->expects($this->once())
->method('generate')
->with('homepage', ['_locale' => 'en'])
->willReturn('/en');
$sut = new RedirectToLocaleSubscriber($urlGenerator, $this->createLocaleService(), $storage);
$event = $this->createRequestEvent('/', ['Accept-Language' => 'es-ES,es;q=0.9']);
$sut->onKernelRequest($event);
$response = $event->getResponse();
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/en', $response->headers->get('Location'));
}
private function createLocaleService(): LocaleService
{
return new LocaleService([
'de' => [...LocaleService::DEFAULT_SETTINGS, 'translation' => true],
'en' => [...LocaleService::DEFAULT_SETTINGS, 'translation' => true],
'fr' => [...LocaleService::DEFAULT_SETTINGS, 'translation' => true],
]);
}
/**
* @param array<string, string> $headers
*/
private function createRequestEvent(string $uri, array $headers = []): RequestEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
$request = Request::create($uri, 'GET', [], [], [], ['HTTP_HOST' => 'www.kimai.test', 'HTTPS' => 'on']);
foreach ($headers as $name => $value) {
$request->headers->set($name, $value);
}
return new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
}
}

View File

@@ -0,0 +1,145 @@
<?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\EventSubscriber\UserEnvironmentSubscriber;
use App\Twig\LocaleFormatExtensions;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\FinishRequestEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
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\Authorization\AuthorizationCheckerInterface;
#[CoversClass(UserEnvironmentSubscriber::class)]
class UserEnvironmentSubscriberTest extends TestCase
{
private string $defaultLocale;
private string $defaultTimezone;
protected function setUp(): void
{
$this->defaultLocale = \Locale::getDefault();
$this->defaultTimezone = date_default_timezone_get();
}
protected function tearDown(): void
{
\Locale::setDefault($this->defaultLocale);
date_default_timezone_set($this->defaultTimezone);
}
public function testGetSubscribedEvents(): void
{
self::assertEquals([
KernelEvents::REQUEST => ['prepareEnvironment', -10],
KernelEvents::FINISH_REQUEST => ['restoreLocale', -20],
], UserEnvironmentSubscriber::getSubscribedEvents());
}
public function testPrepareEnvironmentUsesRequestLocaleWithoutAuthenticatedUser(): void
{
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn(null);
$auth = $this->createMock(AuthorizationCheckerInterface::class);
$auth->expects($this->never())->method('isGranted');
$localeExtension = $this->createLocaleFormatExtensions();
$sut = new UserEnvironmentSubscriber($storage, $auth, $localeExtension);
$sut->prepareEnvironment($this->createRequestEvent('fr', true));
self::assertSame('fr', \Locale::getDefault());
self::assertSame('fr', $localeExtension->getLocale());
self::assertSame($this->defaultTimezone, date_default_timezone_get());
}
public function testPrepareEnvironmentUsesUserLocaleTimezoneAndPermission(): void
{
$user = new User();
$user->setLocale('de');
$user->setTimezone('Europe/Berlin');
$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);
$auth = $this->createMock(AuthorizationCheckerInterface::class);
$auth->expects($this->once())->method('isGranted')->with('view_all_data')->willReturn(true);
$localeExtension = $this->createLocaleFormatExtensions();
$sut = new UserEnvironmentSubscriber($storage, $auth, $localeExtension);
$sut->prepareEnvironment($this->createRequestEvent('en', true));
self::assertSame('de', \Locale::getDefault());
self::assertSame('de', $localeExtension->getLocale());
self::assertSame('Europe/Berlin', date_default_timezone_get());
self::assertTrue($user->canSeeAllData());
}
public function testRestoreLocaleAfterSubRequest(): void
{
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn(null);
$auth = $this->createMock(AuthorizationCheckerInterface::class);
$auth->expects($this->never())->method('isGranted');
$localeExtension = $this->createLocaleFormatExtensions();
$sut = new UserEnvironmentSubscriber($storage, $auth, $localeExtension);
$sut->prepareEnvironment($this->createRequestEvent('de', true));
\Locale::setDefault('it');
$localeExtension->setLocale('it');
$sut->restoreLocale($this->createFinishRequestEvent(false));
self::assertSame('de', \Locale::getDefault());
self::assertSame('de', $localeExtension->getLocale());
}
private function createLocaleFormatExtensions(): LocaleFormatExtensions
{
return new LocaleFormatExtensions(new LocaleService([
'de' => LocaleService::DEFAULT_SETTINGS,
'en' => LocaleService::DEFAULT_SETTINGS,
'fr' => LocaleService::DEFAULT_SETTINGS,
'it' => LocaleService::DEFAULT_SETTINGS,
]));
}
private function createRequestEvent(string $locale, bool $mainRequest): RequestEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
$request = new Request();
$request->setLocale($locale);
return new RequestEvent($kernel, $request, $mainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
}
private function createFinishRequestEvent(bool $mainRequest): FinishRequestEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
$request = new Request();
return new FinishRequestEvent($kernel, $request, $mainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
}
}

View File

@@ -9,20 +9,214 @@
namespace App\Tests\EventSubscriber;
use App\Entity\User;
use App\EventSubscriber\WizardSubscriber;
use App\Tests\Mocks\SystemConfigurationFactory;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[CoversClass(WizardSubscriber::class)]
class WizardSubscriberTest extends TestCase
{
public function testGetSubscribedEvents(): void
{
$events = WizardSubscriber::getSubscribedEvents();
self::assertArrayHasKey(KernelEvents::REQUEST, $events);
$methodName = $events[KernelEvents::REQUEST][0];
self::assertIsString($methodName);
self::assertTrue(method_exists(WizardSubscriber::class, $methodName));
self::assertEquals([KernelEvents::REQUEST => ['onKernelRequest', -30]], WizardSubscriber::getSubscribedEvents());
}
public function testOnKernelRequestIgnoresSubRequest(): void
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->never())->method('getToken');
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
$event = $this->createRequestEvent('/dashboard', false);
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresMissingToken(): void
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->never())->method('isGranted');
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn(null);
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
/**
* @return iterable<array{string}>
*/
public static function provideExcludedUris(): iterable
{
yield ['/api/timesheets'];
yield ['/register/new'];
yield ['/wizard/intro'];
}
#[DataProvider('provideExcludedUris')]
public function testOnKernelRequestIgnoresExcludedUris(string $uri): void
{
$token = $this->createMock(TokenInterface::class);
$token->expects($this->never())->method('getUser');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->never())->method('isGranted');
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
$event = $this->createRequestEvent($uri);
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresNonUserToken(): void
{
$token = $this->createMock(TokenInterface::class);
$token->expects($this->once())->method('getUser')->willReturn($this->createMock(UserInterface::class));
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->never())->method('isGranted');
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
'user' => [
'wizard' => true,
]
]));
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresUserWithoutFullAuthentication(): void
{
$user = new User();
$token = $this->createUserToken($user);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(false);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
'user' => [
'wizard' => true,
]
]));
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestIgnoresWizardForRegularUserIfDisabled(): void
{
$user = new User();
$token = $this->createUserToken($user);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->never())->method('generate');
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(true);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
'user' => [
'wizard' => false,
]
]));
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testOnKernelRequestRedirectsToFirstUnseenWizard(): void
{
$user = new User();
$user->setWizardAsSeen('intro');
$token = $this->createUserToken($user);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator
->expects($this->once())
->method('generate')
->with('wizard', ['wizard' => 'profile'])
->willReturn('/wizard/profile');
$security = $this->createMock(AuthorizationCheckerInterface::class);
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(true);
$storage = $this->createMock(TokenStorageInterface::class);
$storage->expects($this->once())->method('getToken')->willReturn($token);
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
'user' => [
'wizard' => true,
]
]));
$event = $this->createRequestEvent('/dashboard');
$sut->onKernelRequest($event);
$response = $event->getResponse();
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/wizard/profile', $response->headers->get('Location'));
}
private function createUserToken(User $user): TokenInterface
{
$token = $this->createMock(TokenInterface::class);
$token->expects($this->once())->method('getUser')->willReturn($user);
return $token;
}
private function createRequestEvent(string $uri, bool $mainRequest = true): RequestEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
$request = Request::create($uri);
return new RequestEvent($kernel, $request, $mainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
}
}

View File

@@ -0,0 +1,77 @@
<?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\Pdf;
use App\Pdf\SafeRemoteContentClient;
use Mpdf\PsrHttpMessageShim\Request;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
#[CoversClass(SafeRemoteContentClient::class)]
class SafeRemoteContentClientTest extends TestCase
{
public function testSuccessfulResponseIsForwarded(): void
{
$client = new MockHttpClient(
new MockResponse('image-bytes', ['http_code' => 200])
);
$sut = new SafeRemoteContentClient($client);
$response = $sut->sendRequest(new Request('GET', 'https://example.com/logo.png'));
self::assertSame(200, $response->getStatusCode());
self::assertSame('image-bytes', $response->getBody()->getContents());
}
public function testNon2xxResponseIsForwardedWithoutThrowing(): void
{
$client = new MockHttpClient(
new MockResponse('not found', ['http_code' => 404])
);
$sut = new SafeRemoteContentClient($client);
$response = $sut->sendRequest(new Request('GET', 'https://example.com/missing.png'));
self::assertSame(404, $response->getStatusCode());
}
public function testTransportExceptionResultsInNon2xxResponse(): void
{
// Simulates NoPrivateNetworkHttpClient blocking the request, a DNS
// failure, or a connection timeout — none of which must crash the
// PDF rendering pipeline.
$client = new MockHttpClient(static function (): MockResponse {
throw new TransportException('IP blocked');
});
$sut = new SafeRemoteContentClient($client);
$response = $sut->sendRequest(new Request('GET', 'http://127.0.0.1/internal'));
self::assertSame(502, $response->getStatusCode());
}
public function testRequestIsBlockedWhenWrappedWithNoPrivateNetworkHttpClient(): void
{
// Wraps a mock client that would otherwise succeed. The decorator
// must reject the localhost URL before any request is dispatched.
$inner = new MockHttpClient(new MockResponse('should-not-be-reached'));
$safe = new NoPrivateNetworkHttpClient($inner);
$sut = new SafeRemoteContentClient($safe);
$response = $sut->sendRequest(new Request('GET', 'http://127.0.0.1/internal'));
self::assertSame(502, $response->getStatusCode());
self::assertSame('', $response->getBody()->getContents());
}
}

View File

@@ -0,0 +1,81 @@
<?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\Security;
use App\Entity\User;
use App\Tests\KernelTestTrait;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Http\LoginLink\Exception\InvalidLoginLinkException;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
/**
* Regression test for GHSA-m492-gv72-xvxj: a login link (used for password
* reset and admin on-demand login) must stop working once the user's password
* has been changed.
*/
#[Group('integration')]
class LoginLinkTest extends KernelTestCase
{
use KernelTestTrait;
private Request $request;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
// the login link handler is firewall-aware and needs an active request
// on the stack to resolve the firewall it belongs to
$this->request = Request::create('http://localhost/');
$stack = self::getContainer()->get(RequestStack::class);
self::assertInstanceOf(RequestStack::class, $stack);
$stack->push($this->request);
}
private function getLoginLinkHandler(): LoginLinkHandlerInterface
{
/** @var LoginLinkHandlerInterface $handler */
$handler = self::getContainer()->get(LoginLinkHandlerInterface::class);
return $handler;
}
public function testLoginLinkIsValidBeforePasswordChange(): void
{
$user = $this->getUserByRole(User::ROLE_USER);
$handler = $this->getLoginLinkHandler();
$link = $handler->createLoginLink($user, $this->request);
$consumed = $handler->consumeLoginLink(Request::create($link->getUrl()));
self::assertSame($user->getUserIdentifier(), $consumed->getUserIdentifier());
}
public function testLoginLinkIsRejectedAfterPasswordChange(): void
{
$user = $this->getUserByRole(User::ROLE_USER);
$handler = $this->getLoginLinkHandler();
$link = $handler->createLoginLink($user, $this->request);
// simulate the user completing the password reset wizard: the password
// hash changes, which must invalidate the signature of the old link
$user->setPassword('$2y$13$changedchangedchangedchangedchangedchangedchangedchangedchg');
$this->getEntityManager()->flush();
$this->expectException(InvalidLoginLinkException::class);
$handler->consumeLoginLink(Request::create($link->getUrl()));
}
}

View File

@@ -859,6 +859,90 @@ class RolePermissionManagerTest extends TestCase
self::assertFalse($sut->checkUserAccess($subject, $requester));
}
public function testCheckUserAccessAllowsDisabledSubjectWhenOnlyEnabledIsFalse(): void
{
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(false);
$requester = self::userWithId(2);
$team = new Team('Support');
$team->addUser($subject);
$team->addTeamlead($requester);
// with $onlyEnabled = true (default) the disabled flag denies access
self::assertFalse($sut->checkUserAccess($subject, $requester));
// with $onlyEnabled = false the disabled flag is ignored and the teamlead path grants access
self::assertTrue($sut->checkUserAccess($subject, $requester, false));
}
public function testCheckUserAccessOnlyEnabledFalseStillRequiresAccessPath(): void
{
// disabling the "enabled" check must not bypass the rest of the access logic:
// a requester without team relation must still be denied.
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(false);
(new Team('Subject team'))->addUser($subject);
$requester = self::userWithId(2);
$requester->setRoles([User::ROLE_TEAMLEAD]);
self::assertFalse($sut->checkUserAccess($subject, $requester, false));
}
public function testCheckUserAccessOnlyEnabledFalseStillBlocksSystemAccountSubject(): void
{
// the system-account guard sits below the "enabled" check, so it must still
// apply when $onlyEnabled = false.
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(false);
$subject->setSystemAccount(true);
$requester = self::userWithId(2);
$team = new Team('Support');
$team->addUser($subject);
$team->addTeamlead($requester);
self::assertFalse($sut->checkUserAccess($subject, $requester, false));
}
public function testCheckUserAccessOnlyEnabledFalseGrantsAdminFallbackForDisabledTeamlessUser(): void
{
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(false);
$requester = self::userWithId(2);
$requester->setRoles([User::ROLE_ADMIN]);
self::assertTrue($subject->isRegularUserOnly());
self::assertSame([], $subject->getTeams());
self::assertFalse($sut->checkUserAccess($subject, $requester));
self::assertTrue($sut->checkUserAccess($subject, $requester, false));
}
public function testCheckUserAccessOnlyEnabledFalseStillGrantsSuperAdmin(): void
{
// super-admin / canSeeAllData is decided before the "enabled" check,
// so the result must not change with the flag.
$sut = $this->createSut();
$subject = self::userWithId(1);
$subject->setEnabled(false);
$requester = self::userWithId(2);
$requester->setSuperAdmin(true);
self::assertTrue($sut->checkUserAccess($subject, $requester));
self::assertTrue($sut->checkUserAccess($subject, $requester, false));
}
public function testCheckUserAccessDeniesSystemAccountForNonSystemRequester(): void
{
$sut = $this->createSut();

View File

@@ -28,7 +28,7 @@ class ParsedownExtensionTest extends TestCase
| Another entry | € 111 |
| | |
| Total | A lot |');
self::assertStringStartsWith('<table class="table">', $html);
self::assertStringStartsWith('<table class="table table-striped table-vcenter">', $html);
}
public function testHeaderIsNotConverted(): void
@@ -39,4 +39,23 @@ class ParsedownExtensionTest extends TestCase
');
self::assertEquals('<p># Foo</p>', $html);
}
/**
* Markdown image syntax must never emit an `<img>` tag, otherwise mPDF
* (server-side) or the browser (UI) would auto-fetch the remote URL.
*
* @see https://github.com/kimai/kimai/security/advisories/GHSA-pj8j-p4g4-4vw8
*/
public function testMarkdownImageIsRewrittenAsLink(): void
{
$sut = new ParsedownExtension();
$sut->setSafeMode(true);
$sut->setMarkupEscaped(true);
$html = $sut->text('![probe](http://attacker.example/p.png)');
self::assertStringNotContainsString('<img', $html);
self::assertStringContainsString('href="http://attacker.example/p.png"', $html);
self::assertStringContainsString('>probe</a>', $html);
}
}

View File

@@ -50,4 +50,74 @@ class ParsedownTest extends TestCase
<h1 id="foo-1">Foo</h1>
<h1 id="foo-2">Foo</h1>', $html);
}
/**
* Markdown image syntax must never emit an `<img>` tag, otherwise mPDF
* (server-side) or the browser (UI) would auto-fetch the remote URL.
*
* @see https://github.com/kimai/kimai/security/advisories/GHSA-pj8j-p4g4-4vw8
*/
public function testMarkdownImageIsRewrittenAsLink(): void
{
$sut = new Parsedown();
$sut->setSafeMode(true);
$sut->setMarkupEscaped(true);
$html = $sut->text('![probe](http://attacker.example/p.png)');
self::assertStringNotContainsString('<img', $html);
self::assertStringContainsString('href="http://attacker.example/p.png"', $html);
self::assertStringContainsString('>probe</a>', $html);
self::assertStringContainsString('target="_blank"', $html);
self::assertStringContainsString('rel="noopener noreferrer"', $html);
}
public function testMarkdownImageWithoutAltUsesUrlAsLabel(): void
{
$sut = new Parsedown();
$sut->setSafeMode(true);
$sut->setMarkupEscaped(true);
$html = $sut->text('![](http://attacker.example/p.png)');
self::assertStringNotContainsString('<img', $html);
self::assertStringContainsString('>http://attacker.example/p.png</a>', $html);
}
public function testReferenceStyleImageIsRewrittenAsLink(): void
{
$sut = new Parsedown();
$sut->setSafeMode(true);
$sut->setMarkupEscaped(true);
$html = $sut->text("![probe][ref]\n\n[ref]: http://attacker.example/p.png");
self::assertStringNotContainsString('<img', $html);
self::assertStringContainsString('href="http://attacker.example/p.png"', $html);
self::assertStringContainsString('>probe</a>', $html);
}
public function testRawHtmlImageIsEscaped(): void
{
$sut = new Parsedown();
$sut->setSafeMode(true);
$sut->setMarkupEscaped(true);
$html = $sut->text('<img src="http://attacker.example/p.png">');
self::assertStringNotContainsString('<img', $html);
self::assertStringContainsString('&lt;img', $html);
}
public function testJavascriptUrlInImageIsNeutralised(): void
{
$sut = new Parsedown();
$sut->setSafeMode(true);
$sut->setMarkupEscaped(true);
$html = $sut->text('![x](javascript:alert(1))');
self::assertStringNotContainsString('<img', $html);
self::assertStringNotContainsString('href="javascript:', $html);
}
}

View File

@@ -192,19 +192,6 @@ class TimesheetVoterTest extends AbstractVoterTestCase
$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);
@@ -653,6 +640,99 @@ class TimesheetVoterTest extends AbstractVoterTestCase
$this->assertVote($requester, $timesheet, 'view', VoterInterface::ACCESS_GRANTED);
}
/**
* Reproduces GHSA-c6w6-57jj-62vh.
*
* After a user loses team access to a project, "restart" (start) and
* "duplicate" must NOT be allowed on one of their own historical timesheets:
* both operations derive a brand-new record from the old entry and would
* therefore create a new write under the now-unauthorized project/activity.
*
* The "_own_timesheet" branch in the voter currently short-circuits before
* checkTeamAccess*() runs, so this test FAILS on vulnerable code (the voter
* returns ACCESS_GRANTED) and documents the expected secure behaviour.
*/
public function testStartAndDuplicateDeniedAfterProjectAccessRevoked(): void
{
$owner = self::getUser(1, User::ROLE_USER);
// The project is now restricted to a team the owner is NOT a member of.
// This is the post-revocation state from the advisory's PoC.
$restrictedTeam = new Team('restricted after revocation');
$customer = new Customer('Acme');
$project = new Project();
$project->setCustomer($customer);
$project->addTeam($restrictedTeam);
$activity = new Activity();
$activity->setProject($project);
$timesheet = new Timesheet();
$timesheet->setUser($owner);
$timesheet->setProject($project);
$timesheet->setActivity($activity);
$this->assertVote($owner, $timesheet, 'start', VoterInterface::ACCESS_DENIED);
$this->assertVote($owner, $timesheet, 'duplicate', VoterInterface::ACCESS_DENIED);
}
/**
* Reproduces GHSA-c6w6-57jj-62vh for activity-level restriction.
*
* Even if the project is unrestricted, a restricted activity (a team the
* owner is no longer in) must block restart/duplicate.
*/
public function testStartAndDuplicateDeniedAfterActivityAccessRevoked(): void
{
$owner = self::getUser(1, User::ROLE_USER);
$customer = new Customer('Acme');
$project = new Project();
$project->setCustomer($customer);
$activity = new Activity();
$activity->setProject($project);
$activity->addTeam(new Team('restricted after revocation'));
$timesheet = new Timesheet();
$timesheet->setUser($owner);
$timesheet->setProject($project);
$timesheet->setActivity($activity);
$this->assertVote($owner, $timesheet, 'start', VoterInterface::ACCESS_DENIED);
$this->assertVote($owner, $timesheet, 'duplicate', VoterInterface::ACCESS_DENIED);
}
/**
* Positive control for GHSA-c6w6-57jj-62vh: when the owner still has team
* access to the project and activity, restart/duplicate stay allowed.
* Guards the fix against over-restriction.
*/
public function testStartAndDuplicateGrantedWhenOwnerStillHasProjectAccess(): void
{
$owner = self::getUser(1, User::ROLE_USER);
$team = new Team('still a member');
$team->addUser($owner);
$customer = new Customer('Acme');
$project = new Project();
$project->setCustomer($customer);
$project->addTeam($team);
$activity = new Activity();
$activity->setProject($project);
$timesheet = new Timesheet();
$timesheet->setUser($owner);
$timesheet->setProject($project);
$timesheet->setActivity($activity);
$this->assertVote($owner, $timesheet, 'start', VoterInterface::ACCESS_GRANTED);
$this->assertVote($owner, $timesheet, 'duplicate', VoterInterface::ACCESS_GRANTED);
}
private static function getTimesheetFor(User $owner, ?Team $customerTeam = null, ?Team $projectTeam = null, ?Team $activityTeam = null): Timesheet
{
$customer = new Customer('Acme');

View File

@@ -10,6 +10,7 @@
namespace App\Tests\Voter;
use App\Entity\InvoiceTemplate;
use App\Entity\Team;
use App\Entity\User;
use App\Voter\UserVoter;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -119,4 +120,137 @@ class UserVoterTest extends AbstractVoterTestCase
self::assertEquals(VoterInterface::ACCESS_GRANTED, $sut->vote($token, $user, ['view_team_member']));
self::assertEquals(VoterInterface::ACCESS_DENIED, $sut->vote($token, $userMock, ['view_team_member']));
}
/**
* Even with the "<attribute>_other_profile" role permission, access to another user's
* profile must additionally pass the team-membership check in
* RolePermissionManager::checkUserAccess().
*/
public function testOtherProfileRequiresTeamRelation(): void
{
$teamlead = self::getUser(10, User::ROLE_TEAMLEAD);
$teamlead->setEnabled(true);
$foreignUser = self::getUser(11, User::ROLE_USER);
$foreignUser->setEnabled(true);
// give the foreign user a team that the teamlead is NOT part of,
// so the special "subject has no teams" fallback does not kick in
$team = new Team('foreign team');
$foreignUser->addTeam($team);
$permissions = [
'ROLE_TEAMLEAD' => ['view_other_profile', 'edit_other_profile'],
];
$rpm = $this->getRolePermissionManager($permissions, true);
$voter = new UserVoter($rpm);
$token = new UsernamePasswordToken($teamlead, 'bar', $teamlead->getRoles());
// the role permission "view_other_profile" exists, but the team relation is missing
self::assertEquals(VoterInterface::ACCESS_DENIED, $voter->vote($token, $foreignUser, ['view']));
self::assertEquals(VoterInterface::ACCESS_DENIED, $voter->vote($token, $foreignUser, ['edit']));
}
/**
* Same setup as above, but the current user IS teamlead of one of the subject's
* teams — access should then be granted.
*/
public function testOtherProfileGrantedWhenUserIsTeamleadOfSubject(): void
{
$teamlead = self::getUser(20, User::ROLE_TEAMLEAD);
$teamlead->setEnabled(true);
$member = self::getUser(21, User::ROLE_USER);
$member->setEnabled(true);
$team = new Team('shared team');
$team->addUser($member);
$team->addTeamlead($teamlead);
$permissions = [
'ROLE_TEAMLEAD' => ['view_other_profile', 'edit_other_profile'],
];
$rpm = $this->getRolePermissionManager($permissions, true);
$voter = new UserVoter($rpm);
$token = new UsernamePasswordToken($teamlead, 'bar', $teamlead->getRoles());
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $member, ['view']));
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $member, ['edit']));
}
/**
* Verify that disabled profiles can still be edited (e.g. to reactivate them or check historic data).
*/
public function testOtherProfileAllowedForDisabledSubject(): void
{
$teamlead = self::getUser(30, User::ROLE_TEAMLEAD);
$teamlead->setEnabled(true);
$member = self::getUser(31, User::ROLE_USER);
$member->setEnabled(false);
$team = new Team('shared team');
$team->addUser($member);
$team->addTeamlead($teamlead);
$permissions = [
'ROLE_TEAMLEAD' => ['view_other_profile', 'edit_other_profile'],
];
$rpm = $this->getRolePermissionManager($permissions, true);
$voter = new UserVoter($rpm);
$token = new UsernamePasswordToken($teamlead, 'bar', $teamlead->getRoles());
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $member, ['view']));
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $member, ['edit']));
}
/**
* Special case in checkUserAccess(): if the subject has no teams at all and the
* current user is a teamlead/admin, access is granted (small-installation case).
*/
public function testOtherProfileGrantedForTeamlessSubjectWhenUserIsTeamlead(): void
{
$teamlead = self::getUser(40, User::ROLE_TEAMLEAD);
$teamlead->setEnabled(true);
$lonelyUser = self::getUser(41, User::ROLE_USER);
$lonelyUser->setEnabled(true);
$permissions = [
'ROLE_TEAMLEAD' => ['view_other_profile', 'edit_other_profile'],
];
$rpm = $this->getRolePermissionManager($permissions, true);
$voter = new UserVoter($rpm);
$token = new UsernamePasswordToken($teamlead, 'bar', $teamlead->getRoles());
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $lonelyUser, ['view']));
self::assertEquals(VoterInterface::ACCESS_GRANTED, $voter->vote($token, $lonelyUser, ['edit']));
}
/**
* Without the role permission "<attribute>_other_profile" the voter must deny,
* regardless of any team relation between user and subject.
*/
public function testOtherProfileDeniedWithoutRolePermissionEvenWithTeamRelation(): void
{
$teamlead = self::getUser(50, User::ROLE_TEAMLEAD);
$teamlead->setEnabled(true);
$member = self::getUser(51, User::ROLE_USER);
$member->setEnabled(true);
$team = new Team('shared team');
$team->addUser($member);
$team->addTeamlead($teamlead);
// no "view_other_profile" permission for ROLE_TEAMLEAD
$permissions = [
'ROLE_TEAMLEAD' => ['view_own_profile'],
];
$rpm = $this->getRolePermissionManager($permissions, true);
$voter = new UserVoter($rpm);
$token = new UsernamePasswordToken($teamlead, 'bar', $teamlead->getRoles());
self::assertEquals(VoterInterface::ACCESS_DENIED, $voter->vote($token, $member, ['view']));
}
}

View File

@@ -273,7 +273,7 @@ parameters:
-
message: "#^Parameter \\#5 \\$content of method App\\\\Tests\\\\API\\\\APIControllerBaseTestCase\\:\\:request\\(\\) expects string\\|null, string\\|false given\\.$#"
count: 23
count: 25
path: API/TeamControllerTest.php
-