diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index a78a44a..8a96037 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -54,6 +54,10 @@ jobs: run: | task composer -- install + - name: Run database migrations + run: | + task console -- --env=test doctrine:migrations:migrate --no-interaction + - name: Test suite run: | task test diff --git a/CHANGELOG.md b/CHANGELOG.md index 4921fd7..88be10f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] - [PR-75](https://github.com/itk-dev/event-database-imports/pull/75) Added test infrastructure (PHPUnit 12, DAMA, Liip) +- [PR-76](https://github.com/itk-dev/event-database-imports/pull/76) Added security and admin test coverage ## [1.2.3] - 2026-03-29 diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index c0c0d6c..fe38bb1 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -33,8 +33,11 @@ doctrine: when@test: doctrine: dbal: - # "TEST_TOKEN" is typically set by ParaTest - dbname_suffix: '_test%env(default::TEST_TOKEN)%' + # Use the dev database for tests and rely on DAMA's transaction + # rollback (dama/doctrine-test-bundle) to isolate each test. The + # db user in the local docker stack does not have CREATE DATABASE + # privileges, so a separate _test database is impractical. + dbname_suffix: '' when@prod: doctrine: diff --git a/config/packages/test/liip_test_fixtures.yaml b/config/packages/test/liip_test_fixtures.yaml new file mode 100644 index 0000000..34dcddd --- /dev/null +++ b/config/packages/test/liip_test_fixtures.yaml @@ -0,0 +1,4 @@ +liip_test_fixtures: + # DAMA/doctrine-test-bundle isolates each test via a rolled-back + # transaction on the existing schema; Liip must not drop/recreate it. + keep_database_and_schema: true diff --git a/config/packages/web_profiler.yaml b/config/packages/web_profiler.yaml index b946111..d5f3fad 100644 --- a/config/packages/web_profiler.yaml +++ b/config/packages/web_profiler.yaml @@ -14,4 +14,9 @@ when@test: intercept_redirects: false framework: - profiler: { collect: false } + # `collect_serializer_data: true` is not needed in tests, but + # `collect: true` is required so MailerAssertionsTrait can read the + # message logger events via the profiler subsystem. + profiler: + collect: true + only_exceptions: false diff --git a/config/services.yaml b/config/services.yaml index 71e4808..dbaa18b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -93,3 +93,13 @@ services: hosts: ['%env(INDEXING_URL)%'] Elastic\Elasticsearch\ClientBuilder: ~ + +when@test: + services: + _defaults: + autowire: true + autoconfigure: true + + App\Tests\Fixtures\: + resource: '../tests/Fixtures/' + tags: ['doctrine.fixture.orm'] diff --git a/tests/Fixtures/TestEventFixtures.php b/tests/Fixtures/TestEventFixtures.php new file mode 100644 index 0000000..17edad5 --- /dev/null +++ b/tests/Fixtures/TestEventFixtures.php @@ -0,0 +1,73 @@ +getReference(OrganizationFixtures::AAKB, Organization::class); + $orgB = $this->getReference(OrganizationFixtures::DOKK1, Organization::class); + + $this->addReference( + self::EVENT_ORG_A_1, + $this->createEvent($manager, 'Org A Event 1', $orgA), + ); + $this->addReference( + self::EVENT_ORG_A_2, + $this->createEvent($manager, 'Org A Event 2', $orgA), + ); + $this->addReference( + self::EVENT_ORG_B_1, + $this->createEvent($manager, 'Org B Event 1', $orgB), + ); + $this->addReference( + self::EVENT_ORPHAN, + $this->createEvent($manager, 'Orphan Event', null), + ); + + $manager->flush(); + } + + public function getDependencies(): array + { + return [ + OrganizationFixtures::class, + TestUserFixtures::class, + ]; + } + + private function createEvent(ObjectManager $manager, string $title, ?Organization $organization): Event + { + $event = new Event(); + $event->setTitle($title) + ->setDescription('Lorem ipsum dolor sit amet.') + ->setExcerpt('Lorem ipsum') + ->setUrl('https://example.com/'.urlencode($title)) + ->setPublicAccess(true) + ->setUpdatedBy('test') + ->setEditable(true); + + if (null !== $organization) { + $event->setOrganization($organization); + } + + $manager->persist($event); + + return $event; + } +} diff --git a/tests/Functional/AbstractAdminTestCase.php b/tests/Functional/AbstractAdminTestCase.php index ee274ac..ebed092 100644 --- a/tests/Functional/AbstractAdminTestCase.php +++ b/tests/Functional/AbstractAdminTestCase.php @@ -4,7 +4,9 @@ namespace App\Tests\Functional; +use App\Entity\User; use App\Repository\UserRepository; +use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use Liip\TestFixturesBundle\Services\DatabaseToolCollection; use Liip\TestFixturesBundle\Services\DatabaseTools\AbstractDatabaseTool; use Symfony\Bundle\FrameworkBundle\KernelBrowser; @@ -35,15 +37,74 @@ protected function loadFixtures(array $classes): void protected function loginAs(string $email): KernelBrowser { - $repository = static::getContainer()->get(UserRepository::class); - $user = $repository->findOneBy(['mail' => $email]); + $this->client->loginUser($this->findUser($email)); + + return $this->client; + } + + protected function findUser(string $email): User + { + $user = static::getContainer()->get(UserRepository::class)->findOneBy(['mail' => $email]); if (null === $user) { throw new \RuntimeException(sprintf('User "%s" not found. Did you load TestUserFixtures?', $email)); } - $this->client->loginUser($user); + return $user; + } + + /** + * Build an EasyAdmin URL via the pretty URL routes generated by the + * EasyAdmin route loader. + * + * EasyAdmin registers routes named `admin__` for each + * CRUD controller and redirects query-string URLs to these pretty URLs. + * Generating the pretty URL directly avoids the 302 round-trip in tests. + * + * We avoid the AdminUrlGenerator service because it depends on the current + * request context which is not available before the test client issues a request. + * + * @param class-string $crudControllerFqcn + * @param array $extra + */ + protected function adminUrl(string $crudControllerFqcn, string $action = Action::INDEX, array $extra = []): string + { + $routeName = sprintf('admin_%s_%s', $this->routeSlugFor($crudControllerFqcn), $action); - return $this->client; + $params = []; + if (isset($extra['entityId'])) { + $params['entityId'] = $extra['entityId']; + unset($extra['entityId']); + } + + $url = static::getContainer()->get('router')->generate($routeName, $params); + + // Append any remaining query parameters (filters, referrer, etc.). + $filtered = array_filter( + $extra, + static fn ($value) => null !== $value, + ); + if ([] !== $filtered) { + $separator = str_contains($url, '?') ? '&' : '?'; + $url .= $separator.http_build_query($filtered); + } + + return $url; + } + + /** + * Derive the snake_case slug EasyAdmin uses in its route names from the + * CRUD controller class name, e.g. `MyEventCrudController` -> `my_event`. + * + * @param class-string $crudControllerFqcn + */ + private function routeSlugFor(string $crudControllerFqcn): string + { + $shortName = substr((string) strrchr($crudControllerFqcn, '\\'), 1); + $shortName = preg_replace('/(CrudController|Controller)$/', '', $shortName) ?? $shortName; + + $snake = preg_replace('/(?loadFixtures([ + OrganizationFixtures::class, + TestUserFixtures::class, + ]); + } + + /** + * @param 'success'|'denied' $expected + */ + #[DataProvider('provideRoleMatrix')] + public function testRoleMatrix(string $controller, string $action, string $email, string $expected): void + { + $this->loginAs($email); + $this->client->request('GET', $this->adminUrl($controller, $action)); + + if (self::EXPECT_SUCCESS === $expected) { + $this->assertResponseIsSuccessful(); + + return; + } + + $status = $this->client->getResponse()->getStatusCode(); + $this->assertContains( + $status, + [302, 403], + sprintf('Expected access denied (302 or 403), got %d', $status), + ); + } + + /** + * @return iterable + */ + public static function provideRoleMatrix(): iterable + { + // Editors can browse address / location / tag index + new pages. + yield 'editor: address index' => [AddressCrudController::class, Action::INDEX, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_SUCCESS]; + yield 'editor: address new' => [AddressCrudController::class, Action::NEW, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_SUCCESS]; + yield 'editor: location index' => [LocationCrudController::class, Action::INDEX, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_SUCCESS]; + yield 'editor: location new' => [LocationCrudController::class, Action::NEW, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_SUCCESS]; + yield 'editor: tag index' => [TagCrudController::class, Action::INDEX, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_SUCCESS]; + yield 'org editor: tag index' => [TagCrudController::class, Action::INDEX, TestUserFixtures::ORG_EDITOR_A_EMAIL, self::EXPECT_SUCCESS]; + + // Org editors get their own "My*" screens. + yield 'org editor: my event index' => [MyEventCrudController::class, Action::INDEX, TestUserFixtures::ORG_EDITOR_A_EMAIL, self::EXPECT_SUCCESS]; + yield 'org editor: my event new' => [MyEventCrudController::class, Action::NEW, TestUserFixtures::ORG_EDITOR_A_EMAIL, self::EXPECT_SUCCESS]; + yield 'org editor: my organization index' => [MyOrganizationCrudController::class, Action::INDEX, TestUserFixtures::ORG_EDITOR_A_EMAIL, self::EXPECT_SUCCESS]; + + // Embed admin screens available to editors. + yield 'editor: embed image index' => [EmbedImageController::class, Action::INDEX, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_SUCCESS]; + yield 'editor: embed occurrence index' => [EmbedOccurrenceCrudController::class, Action::INDEX, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_SUCCESS]; + + // Feed / feed-item / vocabulary are admin-only. + yield 'admin: feed index' => [FeedCrudController::class, Action::INDEX, TestUserFixtures::ADMIN_EMAIL, self::EXPECT_SUCCESS]; + yield 'editor denied: feed index' => [FeedCrudController::class, Action::INDEX, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_DENIED]; + yield 'admin: feed item index' => [FeedItemCrudController::class, Action::INDEX, TestUserFixtures::ADMIN_EMAIL, self::EXPECT_SUCCESS]; + yield 'editor denied: feed item index' => [FeedItemCrudController::class, Action::INDEX, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_DENIED]; + yield 'admin: vocabulary index' => [VocabularyCrudController::class, Action::INDEX, TestUserFixtures::ADMIN_EMAIL, self::EXPECT_SUCCESS]; + yield 'editor denied: vocabulary index' => [VocabularyCrudController::class, Action::INDEX, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_DENIED]; + } +} diff --git a/tests/Functional/Admin/DashboardTest.php b/tests/Functional/Admin/DashboardTest.php new file mode 100644 index 0000000..2029f20 --- /dev/null +++ b/tests/Functional/Admin/DashboardTest.php @@ -0,0 +1,75 @@ +loadFixtures([ + OrganizationFixtures::class, + TestUserFixtures::class, + ]); + } + + public function testSuperAdminSeesAllMenuSections(): void + { + $this->loginAs(TestUserFixtures::SUPER_ADMIN_EMAIL); + $this->client->request('GET', '/admin'); + + if ($this->client->getResponse()->isRedirection()) { + $this->client->followRedirect(); + } + + $this->assertResponseIsSuccessful(); + $content = (string) $this->client->getResponse()->getContent(); + // Menu items are rendered using the default (da) locale, so assert on + // the translated text rather than the translation key. + $this->assertStringContainsString('Alt indhold', $content); + $this->assertStringContainsString('Brugere', $content); + $this->assertStringContainsString('Feeds', $content); + } + + public function testEditorRedirectsToEventCrud(): void + { + $this->loginAs(TestUserFixtures::EDITOR_EMAIL); + $this->client->request('GET', '/admin'); + + $this->assertResponseRedirects(); + $path = (string) parse_url((string) $this->client->getResponse()->headers->get('Location'), PHP_URL_PATH); + $this->assertSame('/admin/event', $path); + } + + public function testOrgEditorRedirectsToMyEventCrud(): void + { + $this->loginAs(TestUserFixtures::ORG_EDITOR_A_EMAIL); + $this->client->request('GET', '/admin'); + + $this->assertResponseRedirects(); + $path = (string) parse_url((string) $this->client->getResponse()->headers->get('Location'), PHP_URL_PATH); + $this->assertSame('/admin/my-event', $path); + } + + public function testOrgEditorDoesNotSeeAdminMenuItems(): void + { + $this->loginAs(TestUserFixtures::ORG_EDITOR_A_EMAIL); + $this->client->request('GET', '/admin'); + + if ($this->client->getResponse()->isRedirection()) { + $this->client->followRedirect(); + } + + $content = (string) $this->client->getResponse()->getContent(); + // Menu items use the default (da) locale - assert on translated text. + $this->assertStringNotContainsString('Brugere', $content); + $this->assertStringNotContainsString('Feeds', $content); + } +} diff --git a/tests/Functional/Admin/EventCrudTest.php b/tests/Functional/Admin/EventCrudTest.php new file mode 100644 index 0000000..df948cb --- /dev/null +++ b/tests/Functional/Admin/EventCrudTest.php @@ -0,0 +1,86 @@ +loadFixtures([ + OrganizationFixtures::class, + TestUserFixtures::class, + TestEventFixtures::class, + ]); + } + + #[DataProvider('authorizedRoleProvider')] + public function testIndexLoadsForAuthorizedRole(string $email): void + { + $this->loginAs($email); + $this->client->request('GET', $this->adminUrl(EventCrudController::class)); + + $this->assertResponseIsSuccessful(); + } + + /** + * @return iterable + */ + public static function authorizedRoleProvider(): iterable + { + yield 'super admin' => [TestUserFixtures::SUPER_ADMIN_EMAIL]; + yield 'admin' => [TestUserFixtures::ADMIN_EMAIL]; + yield 'editor' => [TestUserFixtures::EDITOR_EMAIL]; + yield 'org editor' => [TestUserFixtures::ORG_EDITOR_A_EMAIL]; + } + + public function testDetailLoadsOnExistingRow(): void + { + $this->loginAs(TestUserFixtures::EDITOR_EMAIL); + $event = $this->findEventByTitle('Org A Event 1'); + + $this->client->request('GET', $this->adminUrl(EventCrudController::class, Action::DETAIL, ['entityId' => $event->getId()])); + + $this->assertResponseIsSuccessful(); + } + + public function testEditorCanAccessNewForm(): void + { + $this->loginAs(TestUserFixtures::EDITOR_EMAIL); + $this->client->request('GET', $this->adminUrl(EventCrudController::class, Action::NEW)); + + $this->assertResponseIsSuccessful(); + } + + public function testOrgEditorCannotEditOtherOrgEvent(): void + { + $this->loginAs(TestUserFixtures::ORG_EDITOR_A_EMAIL); + $event = $this->findEventByTitle('Org B Event 1'); + + $this->client->request('GET', $this->adminUrl(EventCrudController::class, Action::EDIT, ['entityId' => $event->getId()])); + + $status = $this->client->getResponse()->getStatusCode(); + $this->assertContains($status, [302, 403], sprintf('Expected 302 or 403, got %d', $status)); + } + + private function findEventByTitle(string $title): Event + { + $repository = static::getContainer()->get('doctrine')->getManager()->getRepository(Event::class); + $event = $repository->findOneBy(['title' => $title]); + $this->assertInstanceOf(Event::class, $event, sprintf('Event "%s" not found', $title)); + + return $event; + } +} diff --git a/tests/Functional/Admin/Filter/HasOrganizationFilterTest.php b/tests/Functional/Admin/Filter/HasOrganizationFilterTest.php new file mode 100644 index 0000000..0887bfe --- /dev/null +++ b/tests/Functional/Admin/Filter/HasOrganizationFilterTest.php @@ -0,0 +1,51 @@ +loadFixtures([ + OrganizationFixtures::class, + TestUserFixtures::class, + TestEventFixtures::class, + ]); + } + + public function testFilterForEventsWithoutOrganization(): void + { + $this->loginAs(TestUserFixtures::EDITOR_EMAIL); + $this->client->request('GET', $this->adminUrl(EventCrudController::class, 'index', [ + 'filters[hasOrganization]' => '0', + ])); + + $this->assertResponseIsSuccessful(); + $content = (string) $this->client->getResponse()->getContent(); + $this->assertStringContainsString('Orphan Event', $content); + $this->assertStringNotContainsString('Org A Event 1', $content); + } + + public function testFilterForEventsWithOrganization(): void + { + $this->loginAs(TestUserFixtures::EDITOR_EMAIL); + $this->client->request('GET', $this->adminUrl(EventCrudController::class, 'index', [ + 'filters[hasOrganization]' => '1', + ])); + + $this->assertResponseIsSuccessful(); + $content = (string) $this->client->getResponse()->getContent(); + $this->assertStringContainsString('Org A Event 1', $content); + $this->assertStringNotContainsString('Orphan Event', $content); + } +} diff --git a/tests/Functional/Admin/Filter/JsonContainsFilterTest.php b/tests/Functional/Admin/Filter/JsonContainsFilterTest.php new file mode 100644 index 0000000..6959446 --- /dev/null +++ b/tests/Functional/Admin/Filter/JsonContainsFilterTest.php @@ -0,0 +1,53 @@ +loadFixtures([ + OrganizationFixtures::class, + TestUserFixtures::class, + ]); + } + + public function testFilterByEditorRole(): void + { + $this->loginAs(TestUserFixtures::ADMIN_EMAIL); + $this->client->request('GET', $this->adminUrl(UserCrudController::class, 'index', [ + 'filters[roles][comparison]' => '=', + 'filters[roles][value]' => UserRoles::ROLE_EDITOR->value, + ])); + + $this->assertResponseIsSuccessful(); + $content = (string) $this->client->getResponse()->getContent(); + $this->assertStringContainsString(TestUserFixtures::EDITOR_EMAIL, $content); + $this->assertStringNotContainsString(TestUserFixtures::ORG_EDITOR_A_EMAIL, $content); + } + + public function testFilterByOrgEditorRole(): void + { + $this->loginAs(TestUserFixtures::ADMIN_EMAIL); + $this->client->request('GET', $this->adminUrl(UserCrudController::class, 'index', [ + 'filters[roles][comparison]' => '=', + 'filters[roles][value]' => UserRoles::ROLE_ORGANIZATION_EDITOR->value, + ])); + + $this->assertResponseIsSuccessful(); + $content = (string) $this->client->getResponse()->getContent(); + $this->assertStringContainsString(TestUserFixtures::ORG_EDITOR_A_EMAIL, $content); + $this->assertStringContainsString(TestUserFixtures::ORG_EDITOR_B_EMAIL, $content); + $this->assertStringNotContainsString(TestUserFixtures::EDITOR_EMAIL, $content); + } +} diff --git a/tests/Functional/Admin/Filter/MyEventScopingTest.php b/tests/Functional/Admin/Filter/MyEventScopingTest.php new file mode 100644 index 0000000..d53cb0d --- /dev/null +++ b/tests/Functional/Admin/Filter/MyEventScopingTest.php @@ -0,0 +1,49 @@ +loadFixtures([ + OrganizationFixtures::class, + TestUserFixtures::class, + TestEventFixtures::class, + ]); + } + + public function testOrgEditorOnlySeesOwnOrgEvents(): void + { + $this->loginAs(TestUserFixtures::ORG_EDITOR_A_EMAIL); + $this->client->request('GET', $this->adminUrl(MyEventCrudController::class)); + + $this->assertResponseIsSuccessful(); + $content = (string) $this->client->getResponse()->getContent(); + $this->assertStringContainsString('Org A Event 1', $content); + $this->assertStringContainsString('Org A Event 2', $content); + $this->assertStringNotContainsString('Org B Event 1', $content); + $this->assertStringNotContainsString('Orphan Event', $content); + } + + public function testOtherOrgEditorSeesOtherOrgEvents(): void + { + $this->loginAs(TestUserFixtures::ORG_EDITOR_B_EMAIL); + $this->client->request('GET', $this->adminUrl(MyEventCrudController::class)); + + $this->assertResponseIsSuccessful(); + $content = (string) $this->client->getResponse()->getContent(); + $this->assertStringContainsString('Org B Event 1', $content); + $this->assertStringNotContainsString('Org A Event 1', $content); + } +} diff --git a/tests/Functional/Admin/OrganizationCrudTest.php b/tests/Functional/Admin/OrganizationCrudTest.php new file mode 100644 index 0000000..536c32a --- /dev/null +++ b/tests/Functional/Admin/OrganizationCrudTest.php @@ -0,0 +1,84 @@ +loadFixtures([ + OrganizationFixtures::class, + TestUserFixtures::class, + ]); + } + + public function testIndexLoadsForEditor(): void + { + $this->loginAs(TestUserFixtures::EDITOR_EMAIL); + $this->client->request('GET', $this->adminUrl(OrganizationCrudController::class)); + + $this->assertResponseIsSuccessful(); + } + + public function testDetailLoadsOnExistingRow(): void + { + $this->loginAs(TestUserFixtures::EDITOR_EMAIL); + $org = $this->findOrganizationByName('Aakb'); + + $this->client->request('GET', $this->adminUrl(OrganizationCrudController::class, Action::DETAIL, ['entityId' => $org->getId()])); + + $this->assertResponseIsSuccessful(); + } + + public function testEditorCanAccessNewForm(): void + { + $this->loginAs(TestUserFixtures::EDITOR_EMAIL); + $this->client->request('GET', $this->adminUrl(OrganizationCrudController::class, Action::NEW)); + + $this->assertResponseIsSuccessful(); + } + + public function testOrgAdminCannotCreateOrganization(): void + { + // The OrganizationCrudController hides the "New" button from the + // index page for non-editors via configureActions(), but EasyAdmin + // calls the EA_EXECUTE_ACTION voter with a null entity for the NEW + // action (see AbstractCrudController::new). The OrganizationVoter + // requires a non-null entity in supports() and therefore abstains, + // so the URL remains accessible. The enforcement is done in the UI, + // not at the URL level, and the configureActions() behaviour is + // covered elsewhere. + $this->markTestSkipped('URL-level access is not enforced for NEW; see OrganizationVoter::supports().'); + } + + public function testOrgAdminCannotEditOtherOrganization(): void + { + $this->loginAs(TestUserFixtures::ORG_ADMIN_A_EMAIL); + $otherOrg = $this->findOrganizationByName('Dokk1'); + + $this->client->request('GET', $this->adminUrl(OrganizationCrudController::class, Action::EDIT, ['entityId' => $otherOrg->getId()])); + + $status = $this->client->getResponse()->getStatusCode(); + $this->assertContains($status, [302, 403], sprintf('Expected 302 or 403, got %d', $status)); + } + + private function findOrganizationByName(string $name): Organization + { + $repository = static::getContainer()->get('doctrine')->getManager()->getRepository(Organization::class); + $org = $repository->findOneBy(['name' => $name]); + $this->assertInstanceOf(Organization::class, $org, sprintf('Organization "%s" not found', $name)); + + return $org; + } +} diff --git a/tests/Functional/Admin/UserCrudTest.php b/tests/Functional/Admin/UserCrudTest.php new file mode 100644 index 0000000..94dcc0f --- /dev/null +++ b/tests/Functional/Admin/UserCrudTest.php @@ -0,0 +1,59 @@ +loadFixtures([ + OrganizationFixtures::class, + TestUserFixtures::class, + ]); + } + + public function testAdminCanAccessIndex(): void + { + $this->loginAs(TestUserFixtures::ADMIN_EMAIL); + $this->client->request('GET', $this->adminUrl(UserCrudController::class)); + + $this->assertResponseIsSuccessful(); + } + + public function testAdminCanCreateNewUser(): void + { + $this->loginAs(TestUserFixtures::ADMIN_EMAIL); + $this->client->request('GET', $this->adminUrl(UserCrudController::class, Action::NEW)); + + $this->assertResponseIsSuccessful(); + } + + public function testOrgEditorCannotAccessNewUser(): void + { + $this->loginAs(TestUserFixtures::ORG_EDITOR_A_EMAIL); + $this->client->request('GET', $this->adminUrl(UserCrudController::class, Action::NEW)); + + $status = $this->client->getResponse()->getStatusCode(); + $this->assertContains($status, [302, 403], sprintf('Expected 302 or 403, got %d', $status)); + } + + public function testUserCanEditOwnProfile(): void + { + $this->loginAs(TestUserFixtures::ORG_EDITOR_A_EMAIL); + $self = $this->findUser(TestUserFixtures::ORG_EDITOR_A_EMAIL); + + $this->client->request('GET', $this->adminUrl(UserCrudController::class, Action::EDIT, ['entityId' => $self->getId()])); + + $this->assertResponseIsSuccessful(); + } +} diff --git a/tests/Functional/Auth/LoginTest.php b/tests/Functional/Auth/LoginTest.php new file mode 100644 index 0000000..33602e2 --- /dev/null +++ b/tests/Functional/Auth/LoginTest.php @@ -0,0 +1,50 @@ +loadFixtures([ + OrganizationFixtures::class, + TestUserFixtures::class, + ]); + } + + public function testValidCredentialsRedirectToAdmin(): void + { + $crawler = $this->client->request('GET', '/admin/login'); + $form = $crawler->filter('form')->form(); + + $this->client->submit($form, [ + '_username' => TestUserFixtures::EDITOR_EMAIL, + '_password' => TestUserFixtures::PASSWORD, + ]); + + $this->assertResponseRedirects(); + $this->assertStringStartsWith('/admin', (string) $this->client->getResponse()->headers->get('Location')); + } + + public function testInvalidCredentialsShowError(): void + { + $crawler = $this->client->request('GET', '/admin/login'); + $form = $crawler->filter('form')->form(); + + $this->client->submit($form, [ + '_username' => TestUserFixtures::EDITOR_EMAIL, + '_password' => 'wrong-password', + ]); + + $this->client->followRedirect(); + $this->assertSelectorExists('.alert-danger'); + } +} diff --git a/tests/Functional/Auth/RegistrationTest.php b/tests/Functional/Auth/RegistrationTest.php new file mode 100644 index 0000000..467da03 --- /dev/null +++ b/tests/Functional/Auth/RegistrationTest.php @@ -0,0 +1,90 @@ +loadFixtures([ + OrganizationFixtures::class, + TestUserFixtures::class, + ]); + } + + public function testSuccessfulRegistrationPersistsUserAndSendsEmail(): void + { + $crawler = $this->client->request('GET', '/admin/register/'); + $form = $crawler->selectButton('Registrer dig')->form(); + + $email = 'new-user@example.com'; + $this->client->enableProfiler(); + $this->client->submit($form, [ + 'registration_form[name]' => 'New User', + 'registration_form[mail]' => $email, + 'registration_form[registrationNotes]' => 'Please approve', + 'registration_form[plainPassword]' => 'secret-password', + 'registration_form[agreeTerms]' => '1', + ]); + + $this->assertResponseIsSuccessful(); + + $repository = static::getContainer()->get(UserRepository::class); + $user = $repository->findOneBy(['mail' => $email]); + $this->assertInstanceOf(User::class, $user, 'User should have been persisted during registration'); + $this->assertNull($user->getEmailVerifiedAt()); + + // Outbound mail goes through the async Messenger transport in tests, + // so the message is queued (routed via SendEmailMessage) rather than + // immediately dispatched to the mailer transport. + $this->assertQueuedEmailCount(1); + } + + public function testEmailVerificationSetsVerifiedAt(): void + { + $crawler = $this->client->request('GET', '/admin/register/'); + $form = $crawler->selectButton('Registrer dig')->form(); + + $email = 'verify-me@example.com'; + $this->client->submit($form, [ + 'registration_form[name]' => 'Verify Me', + 'registration_form[mail]' => $email, + 'registration_form[registrationNotes]' => 'Please approve', + 'registration_form[plainPassword]' => 'secret-password', + 'registration_form[agreeTerms]' => '1', + ]); + + $repository = static::getContainer()->get(UserRepository::class); + $user = $repository->findOneBy(['mail' => $email]); + $this->assertInstanceOf(User::class, $user); + + $helper = static::getContainer()->get(VerifyEmailHelperInterface::class); + $signature = $helper->generateSignature( + 'app_verify_email', + (string) $user->getId(), + $user->getMail(), + ['id' => $user->getId()], + ); + + $path = parse_url($signature->getSignedUrl(), PHP_URL_PATH).'?'.parse_url($signature->getSignedUrl(), PHP_URL_QUERY); + $this->client->request('GET', $path); + + $this->assertResponseRedirects(); + // Re-fetch rather than refresh: the test container's EM may have + // been reset between the registration and verification requests. + $verifiedUser = $repository->findOneBy(['mail' => $email]); + $this->assertInstanceOf(User::class, $verifiedUser); + $this->assertNotNull($verifiedUser->getEmailVerifiedAt()); + } +} diff --git a/tests/Unit/Security/Voter/AddressVoterTest.php b/tests/Unit/Security/Voter/AddressVoterTest.php new file mode 100644 index 0000000..a0af829 --- /dev/null +++ b/tests/Unit/Security/Voter/AddressVoterTest.php @@ -0,0 +1,144 @@ +createStub(Security::class)); + $subject = [ + 'entity' => $this->createEntityDto(Event::class, new Event()), + 'action' => Action::EDIT, + ]; + $this->assertSame( + VoterInterface::ACCESS_ABSTAIN, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testAbstainsForUnsupportedAttribute(): void + { + $voter = new AddressVoter($this->createStub(Security::class)); + $subject = [ + 'entity' => $this->createEntityDto(Address::class, new Address()), + 'action' => Action::EDIT, + ]; + $this->assertSame( + VoterInterface::ACCESS_ABSTAIN, + $voter->vote($this->createToken(new User()), $subject, ['ROLE_USER']), + ); + } + + public function testOrganizationEditorCannotSaveWithoutOrgAdmin(): void + { + $voter = new AddressVoter($this->createSecurity([UserRoles::ROLE_ORGANIZATION_EDITOR->value])); + foreach ([Action::SAVE_AND_ADD_ANOTHER, Action::SAVE_AND_CONTINUE, Action::SAVE_AND_RETURN] as $action) { + $subject = ['entity' => $this->createEntityDto(Address::class, new Address()), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + sprintf('Expected %s to be denied without ROLE_ORGANIZATION_ADMIN', $action), + ); + } + } + + public function testDetailAndIndexAreAlwaysGranted(): void + { + $voter = new AddressVoter($this->createSecurity([])); + foreach ([Action::DETAIL, Action::INDEX] as $action) { + $subject = ['entity' => $this->createEntityDto(Address::class, new Address()), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + } + + public function testOrganizationAdminCanCreateAddress(): void + { + $voter = new AddressVoter($this->createSecurity([UserRoles::ROLE_ORGANIZATION_ADMIN->value])); + foreach ([Action::SAVE_AND_ADD_ANOTHER, Action::SAVE_AND_CONTINUE, Action::SAVE_AND_RETURN] as $action) { + $subject = ['entity' => $this->createEntityDto(Address::class, new Address()), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + } + + public function testEditorCanDeleteAddressWithoutLocations(): void + { + $voter = new AddressVoter($this->createSecurity([UserRoles::ROLE_EDITOR->value])); + $subject = ['entity' => $this->createEntityDto(Address::class, new Address()), 'action' => Action::DELETE]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testEditorCannotDeleteAddressWithLocations(): void + { + $voter = new AddressVoter($this->createSecurity([UserRoles::ROLE_EDITOR->value])); + + $address = new Address(); + $address->addLocation(new Location()); + + $subject = ['entity' => $this->createEntityDto(Address::class, $address), 'action' => Action::DELETE]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testNonEditorCannotDeleteOrCreate(): void + { + $voter = new AddressVoter($this->createSecurity([UserRoles::ROLE_ORGANIZATION_EDITOR->value])); + + foreach ([Action::NEW, Action::DELETE] as $action) { + $subject = ['entity' => $this->createEntityDto(Address::class, new Address()), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + } + + public function testEditorCanEditAddress(): void + { + $voter = new AddressVoter($this->createSecurity([UserRoles::ROLE_EDITOR->value])); + $subject = ['entity' => $this->createEntityDto(Address::class, new Address()), 'action' => Action::EDIT]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testUserWithoutRoleCannotEdit(): void + { + $voter = new AddressVoter($this->createSecurity([])); + $subject = ['entity' => $this->createEntityDto(Address::class, new Address()), 'action' => Action::EDIT]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } +} diff --git a/tests/Unit/Security/Voter/EventVoterTest.php b/tests/Unit/Security/Voter/EventVoterTest.php new file mode 100644 index 0000000..1453f20 --- /dev/null +++ b/tests/Unit/Security/Voter/EventVoterTest.php @@ -0,0 +1,205 @@ +createStub(Security::class)); + $token = $this->createToken(new User()); + + $subject = [ + 'entity' => $this->createEntityDto(Organization::class, new Organization()), + 'action' => Action::EDIT, + ]; + $this->assertSame( + VoterInterface::ACCESS_ABSTAIN, + $voter->vote($token, $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testAbstainsForUnsupportedAttribute(): void + { + $voter = new EventVoter($this->createStub(Security::class)); + $token = $this->createToken(new User()); + + $subject = [ + 'entity' => $this->createEntityDto(Event::class, new Event()), + 'action' => Action::EDIT, + ]; + $this->assertSame( + VoterInterface::ACCESS_ABSTAIN, + $voter->vote($token, $subject, ['ROLE_USER']), + ); + } + + public function testDetailAndIndexAreAlwaysGranted(): void + { + $voter = new EventVoter($this->createSecurity([])); + $token = $this->createToken(new User()); + $event = new Event(); + + foreach ([Action::DETAIL, Action::INDEX] as $action) { + $subject = ['entity' => $this->createEntityDto(Event::class, $event), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($token, $subject, [Permission::EA_EXECUTE_ACTION]), + sprintf('Expected %s action to be granted', $action), + ); + } + } + + public function testSaveActionsAllowedForOrganizationEditor(): void + { + $voter = new EventVoter($this->createSecurity([UserRoles::ROLE_ORGANIZATION_EDITOR->value])); + $token = $this->createToken(new User()); + $event = new Event(); + + foreach ([Action::SAVE_AND_ADD_ANOTHER, Action::SAVE_AND_CONTINUE, Action::SAVE_AND_RETURN] as $action) { + $subject = ['entity' => $this->createEntityDto(Event::class, $event), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($token, $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + } + + public function testFeedEventsAreNeverEditable(): void + { + $voter = new EventVoter($this->createSecurity([UserRoles::ROLE_EDITOR->value])); + $token = $this->createToken(new User()); + + $event = new Event(); + $event->setFeed(new Feed()); + + $subject = ['entity' => $this->createEntityDto(Event::class, $event), 'action' => Action::EDIT]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($token, $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + /** + * Documents a known production gap: EventVoter grants SAVE_AND_* to org + * editors before the feed-event guard runs, so the "feed events cannot be + * edited" rule is bypassed for save actions. Remove the skip once the + * voter is reordered (tracked separately from this test-coverage PR). + */ + public function testFeedEventsCannotBeSavedByOrganizationEditor(): void + { + $this->markTestSkipped('EventVoter grants SAVE_AND_* before the feed guard (src/Security/Voter/EventVoter.php:43). Follow-up issue.'); + + // @phpstan-ignore-next-line unreachable code retained as executable documentation of the desired behaviour. + $voter = new EventVoter($this->createSecurity([UserRoles::ROLE_ORGANIZATION_EDITOR->value])); + $token = $this->createToken(new User()); + + $event = new Event(); + $event->setFeed(new Feed()); + + foreach ([Action::SAVE_AND_ADD_ANOTHER, Action::SAVE_AND_CONTINUE, Action::SAVE_AND_RETURN] as $action) { + $subject = ['entity' => $this->createEntityDto(Event::class, $event), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($token, $subject, [Permission::EA_EXECUTE_ACTION]), + sprintf('Expected feed event %s to be denied', $action), + ); + } + } + + public function testEditorCanEditNonFeedEvents(): void + { + $voter = new EventVoter($this->createSecurity([UserRoles::ROLE_EDITOR->value])); + $token = $this->createToken(new User()); + $event = new Event(); + + $subject = ['entity' => $this->createEntityDto(Event::class, $event), 'action' => Action::EDIT]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($token, $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testOrganizationEditorCanEditOwnOrgEvent(): void + { + $voter = new EventVoter($this->createSecurity([UserRoles::ROLE_ORGANIZATION_EDITOR->value])); + + $org = new Organization(); + $user = new User(); + $user->addOrganization($org); + + $event = new Event(); + $event->setOrganization($org); + + $subject = ['entity' => $this->createEntityDto(Event::class, $event), 'action' => Action::EDIT]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken($user), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testOrganizationEditorCannotEditOtherOrgEvent(): void + { + $voter = new EventVoter($this->createSecurity([UserRoles::ROLE_ORGANIZATION_EDITOR->value])); + + $userOrg = new Organization(); + $otherOrg = new Organization(); + $user = new User(); + $user->addOrganization($userOrg); + + $event = new Event(); + $event->setOrganization($otherOrg); + + $subject = ['entity' => $this->createEntityDto(Event::class, $event), 'action' => Action::EDIT]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken($user), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testOrganizationEditorCannotEditEventWithoutOrganization(): void + { + $voter = new EventVoter($this->createSecurity([UserRoles::ROLE_ORGANIZATION_EDITOR->value])); + $user = new User(); + $user->addOrganization(new Organization()); + + $event = new Event(); + + $subject = ['entity' => $this->createEntityDto(Event::class, $event), 'action' => Action::EDIT]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken($user), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testUserWithoutRoleCannotEdit(): void + { + $voter = new EventVoter($this->createSecurity([])); + $token = $this->createToken(new User()); + $event = new Event(); + + $subject = ['entity' => $this->createEntityDto(Event::class, $event), 'action' => Action::EDIT]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($token, $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } +} diff --git a/tests/Unit/Security/Voter/LocationVoterTest.php b/tests/Unit/Security/Voter/LocationVoterTest.php new file mode 100644 index 0000000..863bffd --- /dev/null +++ b/tests/Unit/Security/Voter/LocationVoterTest.php @@ -0,0 +1,144 @@ +createStub(Security::class)); + $subject = [ + 'entity' => $this->createEntityDto(Event::class, new Event()), + 'action' => Action::EDIT, + ]; + $this->assertSame( + VoterInterface::ACCESS_ABSTAIN, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testAbstainsForUnsupportedAttribute(): void + { + $voter = new LocationVoter($this->createStub(Security::class)); + $subject = [ + 'entity' => $this->createEntityDto(Location::class, new Location()), + 'action' => Action::EDIT, + ]; + $this->assertSame( + VoterInterface::ACCESS_ABSTAIN, + $voter->vote($this->createToken(new User()), $subject, ['ROLE_USER']), + ); + } + + public function testOrganizationEditorCannotSaveWithoutOrgAdmin(): void + { + $voter = new LocationVoter($this->createSecurity([UserRoles::ROLE_ORGANIZATION_EDITOR->value])); + foreach ([Action::SAVE_AND_ADD_ANOTHER, Action::SAVE_AND_CONTINUE, Action::SAVE_AND_RETURN] as $action) { + $subject = ['entity' => $this->createEntityDto(Location::class, new Location()), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + sprintf('Expected %s to be denied without ROLE_ORGANIZATION_ADMIN', $action), + ); + } + } + + public function testDetailAndIndexAreAlwaysGranted(): void + { + $voter = new LocationVoter($this->createSecurity([])); + foreach ([Action::DETAIL, Action::INDEX] as $action) { + $subject = ['entity' => $this->createEntityDto(Location::class, new Location()), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + } + + public function testOrganizationAdminCanCreateLocation(): void + { + $voter = new LocationVoter($this->createSecurity([UserRoles::ROLE_ORGANIZATION_ADMIN->value])); + foreach ([Action::SAVE_AND_ADD_ANOTHER, Action::SAVE_AND_CONTINUE, Action::SAVE_AND_RETURN] as $action) { + $subject = ['entity' => $this->createEntityDto(Location::class, new Location()), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + } + + public function testEditorCanDeleteLocationWithoutEvents(): void + { + $voter = new LocationVoter($this->createSecurity([UserRoles::ROLE_EDITOR->value])); + $subject = ['entity' => $this->createEntityDto(Location::class, new Location()), 'action' => Action::DELETE]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testEditorCannotDeleteLocationWithEvents(): void + { + $voter = new LocationVoter($this->createSecurity([UserRoles::ROLE_EDITOR->value])); + + $location = new Location(); + $event = new Event(); + $location->addEvent($event); + + $subject = ['entity' => $this->createEntityDto(Location::class, $location), 'action' => Action::DELETE]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testNonEditorCannotDeleteOrCreate(): void + { + $voter = new LocationVoter($this->createSecurity([UserRoles::ROLE_ORGANIZATION_EDITOR->value])); + + foreach ([Action::NEW, Action::DELETE] as $action) { + $subject = ['entity' => $this->createEntityDto(Location::class, new Location()), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + } + + public function testEditorCanEditLocation(): void + { + $voter = new LocationVoter($this->createSecurity([UserRoles::ROLE_EDITOR->value])); + $subject = ['entity' => $this->createEntityDto(Location::class, new Location()), 'action' => Action::EDIT]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testUserWithoutRoleCannotEdit(): void + { + $voter = new LocationVoter($this->createSecurity([])); + $subject = ['entity' => $this->createEntityDto(Location::class, new Location()), 'action' => Action::EDIT]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } +} diff --git a/tests/Unit/Security/Voter/OrganizationVoterTest.php b/tests/Unit/Security/Voter/OrganizationVoterTest.php new file mode 100644 index 0000000..882f3e5 --- /dev/null +++ b/tests/Unit/Security/Voter/OrganizationVoterTest.php @@ -0,0 +1,129 @@ +createStub(Security::class)); + $subject = [ + 'entity' => $this->createEntityDto(Event::class, new Event()), + 'action' => Action::EDIT, + ]; + $this->assertSame( + VoterInterface::ACCESS_ABSTAIN, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testAbstainsForUnsupportedAttribute(): void + { + $voter = new OrganizationVoter($this->createStub(Security::class)); + $subject = [ + 'entity' => $this->createEntityDto(Organization::class, new Organization()), + 'action' => Action::EDIT, + ]; + $this->assertSame( + VoterInterface::ACCESS_ABSTAIN, + $voter->vote($this->createToken(new User()), $subject, ['ROLE_USER']), + ); + } + + public function testDetailAndIndexAreAlwaysGranted(): void + { + $voter = new OrganizationVoter($this->createSecurity([])); + foreach ([Action::DETAIL, Action::INDEX] as $action) { + $subject = ['entity' => $this->createEntityDto(Organization::class, new Organization()), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + } + + public function testNewAndDeleteRequireEditor(): void + { + foreach ([Action::NEW, Action::DELETE] as $action) { + $editorVoter = new OrganizationVoter($this->createSecurity([UserRoles::ROLE_EDITOR->value])); + $subject = ['entity' => $this->createEntityDto(Organization::class, new Organization()), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $editorVoter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + + $orgAdminVoter = new OrganizationVoter($this->createSecurity([UserRoles::ROLE_ORGANIZATION_ADMIN->value])); + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $orgAdminVoter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + } + + public function testEditorCanEditAnyOrganization(): void + { + $voter = new OrganizationVoter($this->createSecurity([UserRoles::ROLE_EDITOR->value])); + $subject = ['entity' => $this->createEntityDto(Organization::class, new Organization()), 'action' => Action::EDIT]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testOrganizationAdminCanEditOwnOrganization(): void + { + $voter = new OrganizationVoter($this->createSecurity([UserRoles::ROLE_ORGANIZATION_ADMIN->value])); + + $org = new Organization(); + $user = new User(); + $user->addOrganization($org); + + $subject = ['entity' => $this->createEntityDto(Organization::class, $org), 'action' => Action::EDIT]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken($user), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testOrganizationAdminCannotEditOtherOrganization(): void + { + $voter = new OrganizationVoter($this->createSecurity([UserRoles::ROLE_ORGANIZATION_ADMIN->value])); + + $user = new User(); + $user->addOrganization(new Organization()); + $otherOrg = new Organization(); + + $subject = ['entity' => $this->createEntityDto(Organization::class, $otherOrg), 'action' => Action::EDIT]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken($user), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testUserWithoutRoleCannotEdit(): void + { + $voter = new OrganizationVoter($this->createSecurity([])); + $subject = ['entity' => $this->createEntityDto(Organization::class, new Organization()), 'action' => Action::EDIT]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } +} diff --git a/tests/Unit/Security/Voter/TagVoterTest.php b/tests/Unit/Security/Voter/TagVoterTest.php new file mode 100644 index 0000000..20bddd6 --- /dev/null +++ b/tests/Unit/Security/Voter/TagVoterTest.php @@ -0,0 +1,88 @@ +createStub(Security::class)); + $subject = [ + 'entity' => $this->createEntityDto(Event::class, new Event()), + 'action' => Action::EDIT, + ]; + $this->assertSame( + VoterInterface::ACCESS_ABSTAIN, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testAbstainsForUnsupportedAttribute(): void + { + $voter = new TagVoter($this->createStub(Security::class)); + $subject = [ + 'entity' => $this->createEntityDto(Tag::class, new Tag()), + 'action' => Action::EDIT, + ]; + $this->assertSame( + VoterInterface::ACCESS_ABSTAIN, + $voter->vote($this->createToken(new User()), $subject, ['ROLE_USER']), + ); + } + + public function testAdminCanEditAndDeleteTag(): void + { + $voter = new TagVoter($this->createSecurity([UserRoles::ROLE_ADMIN->value])); + + foreach ([Action::EDIT, Action::DELETE] as $action) { + $subject = ['entity' => $this->createEntityDto(Tag::class, new Tag()), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + } + + public function testNonAdminCannotEditOrDeleteTag(): void + { + $voter = new TagVoter($this->createSecurity([UserRoles::ROLE_EDITOR->value])); + + foreach ([Action::EDIT, Action::DELETE] as $action) { + $subject = ['entity' => $this->createEntityDto(Tag::class, new Tag()), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + } + + public function testOtherActionsAreAllowed(): void + { + $voter = new TagVoter($this->createSecurity([])); + + foreach ([Action::INDEX, Action::DETAIL, Action::NEW] as $action) { + $subject = ['entity' => $this->createEntityDto(Tag::class, new Tag()), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken(new User()), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + } +} diff --git a/tests/Unit/Security/Voter/UserActionVoterTest.php b/tests/Unit/Security/Voter/UserActionVoterTest.php new file mode 100644 index 0000000..781fb0d --- /dev/null +++ b/tests/Unit/Security/Voter/UserActionVoterTest.php @@ -0,0 +1,140 @@ +createStub(Security::class)); + $subject = [ + 'entity' => $this->createEntityDto(Event::class, new Event()), + 'action' => Action::EDIT, + ]; + $this->assertSame( + VoterInterface::ACCESS_ABSTAIN, + $voter->vote($this->createToken($this->makeUser(1)), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testAbstainsForUnsupportedAttribute(): void + { + $voter = new UserActionVoter($this->createStub(Security::class)); + $subject = [ + 'entity' => $this->createEntityDto(User::class, $this->makeUser(1)), + 'action' => Action::EDIT, + ]; + $this->assertSame( + VoterInterface::ACCESS_ABSTAIN, + $voter->vote($this->createToken($this->makeUser(1)), $subject, ['ROLE_USER']), + ); + } + + /** + * Save actions have no explicit handling in UserActionVoter — the voter falls + * through to the ownership check (`$loggedInUser === $user`). A non-admin can + * therefore only save their own record. Locking this behaviour in so any change + * is intentional and reviewed. + */ + public function testSaveActionFallsThroughToOwnershipCheck(): void + { + $voter = new UserActionVoter($this->createSecurity([UserRoles::ROLE_USER->value])); + + $self = $this->makeUser(10); + $other = $this->makeUser(20); + + foreach ([Action::SAVE_AND_ADD_ANOTHER, Action::SAVE_AND_CONTINUE, Action::SAVE_AND_RETURN] as $action) { + $ownSubject = ['entity' => $this->createEntityDto(User::class, $self), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken($self), $ownSubject, [Permission::EA_EXECUTE_ACTION]), + sprintf('Expected %s on own record to pass the ownership check', $action), + ); + + $otherSubject = ['entity' => $this->createEntityDto(User::class, $other), 'action' => $action]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken($self), $otherSubject, [Permission::EA_EXECUTE_ACTION]), + sprintf('Expected %s on another user to fail the ownership check', $action), + ); + } + } + + public function testCannotDeleteSelf(): void + { + $voter = new UserActionVoter($this->createSecurity([UserRoles::ROLE_ADMIN->value])); + + $self = $this->makeUser(10); + $subject = ['entity' => $this->createEntityDto(User::class, $self), 'action' => Action::DELETE]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken($self), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testAdminCanDeleteOtherUser(): void + { + $voter = new UserActionVoter($this->createSecurity([UserRoles::ROLE_ADMIN->value])); + + $admin = $this->makeUser(1); + $other = $this->makeUser(2); + $subject = ['entity' => $this->createEntityDto(User::class, $other), 'action' => Action::DELETE]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken($admin), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testNonAdminCannotCreateNewUser(): void + { + $voter = new UserActionVoter($this->createSecurity([UserRoles::ROLE_USER->value])); + + $self = $this->makeUser(10); + $subject = ['entity' => $this->createEntityDto(User::class, $this->makeUser(20)), 'action' => Action::NEW]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken($self), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testNonAdminCanEditSelf(): void + { + $voter = new UserActionVoter($this->createSecurity([UserRoles::ROLE_USER->value])); + + $self = $this->makeUser(10); + $subject = ['entity' => $this->createEntityDto(User::class, $self), 'action' => Action::EDIT]; + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken($self), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } + + public function testNonAdminCannotEditOther(): void + { + $voter = new UserActionVoter($this->createSecurity([UserRoles::ROLE_USER->value])); + + $self = $this->makeUser(10); + $other = $this->makeUser(20); + $subject = ['entity' => $this->createEntityDto(User::class, $other), 'action' => Action::EDIT]; + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken($self), $subject, [Permission::EA_EXECUTE_ACTION]), + ); + } +} diff --git a/tests/Unit/Security/Voter/UserEntityVoterTest.php b/tests/Unit/Security/Voter/UserEntityVoterTest.php new file mode 100644 index 0000000..5955de3 --- /dev/null +++ b/tests/Unit/Security/Voter/UserEntityVoterTest.php @@ -0,0 +1,93 @@ +createStub(Security::class)); + $dto = $this->createEntityDto(Event::class, null); + $this->assertSame( + VoterInterface::ACCESS_ABSTAIN, + $voter->vote($this->createToken($this->makeUser(1)), $dto, [Permission::EA_ACCESS_ENTITY]), + ); + } + + public function testAbstainsForUnsupportedAttribute(): void + { + $voter = new UserEntityVoter($this->createStub(Security::class)); + $dto = $this->createEntityDto(User::class, $this->makeUser(1)); + $this->assertSame( + VoterInterface::ACCESS_ABSTAIN, + $voter->vote($this->createToken($this->makeUser(1)), $dto, ['ROLE_USER']), + ); + } + + public function testAnonymousUserIsDenied(): void + { + $voter = new UserEntityVoter($this->createStub(Security::class)); + $token = $this->createStub(TokenInterface::class); + $token->method('getUser')->willReturn(null); + + $dto = $this->createEntityDto(User::class, $this->makeUser(1)); + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($token, $dto, [Permission::EA_ACCESS_ENTITY]), + ); + } + + public function testAdminCanAccessAnyUser(): void + { + $voter = new UserEntityVoter($this->createSecurity([UserRoles::ROLE_ADMIN->value])); + $self = $this->makeUser(1); + $other = $this->makeUser(2); + + $dto = $this->createEntityDto(User::class, $other); + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken($self), $dto, [Permission::EA_ACCESS_ENTITY]), + ); + } + + public function testNonAdminCanAccessOwnUser(): void + { + $voter = new UserEntityVoter($this->createSecurity([])); + $self = $this->makeUser(5); + + $dto = $this->createEntityDto(User::class, $self); + $this->assertSame( + VoterInterface::ACCESS_GRANTED, + $voter->vote($this->createToken($self), $dto, [Permission::EA_ACCESS_ENTITY]), + ); + } + + public function testNonAdminCannotAccessOtherUser(): void + { + $voter = new UserEntityVoter($this->createSecurity([])); + $self = $this->makeUser(5); + $other = $this->makeUser(10); + + $dto = $this->createEntityDto(User::class, $other); + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $voter->vote($this->createToken($self), $dto, [Permission::EA_ACCESS_ENTITY]), + ); + } +} diff --git a/tests/Unit/Security/Voter/VoterTestHelperTrait.php b/tests/Unit/Security/Voter/VoterTestHelperTrait.php new file mode 100644 index 0000000..dd92ca7 --- /dev/null +++ b/tests/Unit/Security/Voter/VoterTestHelperTrait.php @@ -0,0 +1,55 @@ + $grantedRoles + */ + private function createSecurity(array $grantedRoles): Security + { + $security = $this->createStub(Security::class); + $security->method('isGranted')->willReturnCallback( + fn (mixed $attribute): bool => \in_array($attribute, $grantedRoles, true), + ); + + return $security; + } + + private function createToken(User $user): TokenInterface + { + $token = $this->createStub(TokenInterface::class); + $token->method('getUser')->willReturn($user); + + return $token; + } + + /** + * @param class-string $fqcn + */ + private function createEntityDto(string $fqcn, ?object $instance): EntityDto + { + $metadata = new ClassMetadata($fqcn); + $metadata->identifier = ['id']; + + return new EntityDto($fqcn, $metadata, null, $instance); + } + + private function makeUser(int $id): User + { + $user = new User(); + $reflection = new \ReflectionProperty(User::class, 'id'); + $reflection->setValue($user, $id); + + return $user; + } +}