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:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
216
tests/EventSubscriber/PasswordResetSubscriberTest.php
Normal file
216
tests/EventSubscriber/PasswordResetSubscriberTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
145
tests/EventSubscriber/UserEnvironmentSubscriberTest.php
Normal file
145
tests/EventSubscriber/UserEnvironmentSubscriberTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
77
tests/Pdf/SafeRemoteContentClientTest.php
Normal file
77
tests/Pdf/SafeRemoteContentClientTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
81
tests/Security/LoginLinkTest.php
Normal file
81
tests/Security/LoginLinkTest.php
Normal 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()));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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('');
|
||||
|
||||
self::assertStringNotContainsString('<img', $html);
|
||||
self::assertStringContainsString('href="http://attacker.example/p.png"', $html);
|
||||
self::assertStringContainsString('>probe</a>', $html);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
|
||||
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('');
|
||||
|
||||
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('<img', $html);
|
||||
}
|
||||
|
||||
public function testJavascriptUrlInImageIsNeutralised(): void
|
||||
{
|
||||
$sut = new Parsedown();
|
||||
$sut->setSafeMode(true);
|
||||
$sut->setMarkupEscaped(true);
|
||||
|
||||
$html = $sut->text(')');
|
||||
|
||||
self::assertStringNotContainsString('<img', $html);
|
||||
self::assertStringNotContainsString('href="javascript:', $html);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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']));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
-
|
||||
|
||||
Reference in New Issue
Block a user