diff --git a/tests/Controller/AuthorizationTest.php b/tests/Controller/AuthorizationTest.php new file mode 100644 index 00000000..4e211301 --- /dev/null +++ b/tests/Controller/AuthorizationTest.php @@ -0,0 +1,222 @@ +. + */ + +namespace App\Tests\Controller; + +use App\Entity\UserSystem\User; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use Symfony\Bundle\FrameworkBundle\KernelBrowser; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\HttpFoundation\Response; + +/** + * Verifies the HTTP access-control boundaries: + * + * The app has an "anonymous" fixture user with readonly permissions, so truly + * public read routes return 200 even without a session. Write-protected routes + * return 401 for unauthenticated requests (not a 302 redirect). + * + * Users: admin (all-allow), user (editor preset), noread (no group/no perms) + */ +#[Group('DB')] +#[Group('slow')] +final class AuthorizationTest extends WebTestCase +{ + // ----------------------------------------------------------------------- + // Data providers + // ----------------------------------------------------------------------- + + /** + * Routes readable by the anonymous user — unauthenticated requests get 200. + */ + public static function publicReadRoutesProvider(): \Generator + { + yield 'homepage' => ['/en/']; + yield 'part view' => ['/en/part/1']; + yield 'statistics' => ['/en/statistics']; + yield 'select category' => ['/en/select_api/category']; + yield 'typeahead tags' => ['/en/typeahead/tags/search/test']; + } + + /** + * Write-protected routes — unauthenticated gets 401 (not 302). + */ + public static function writeProtectedRoutesProvider(): \Generator + { + yield 'part edit' => ['/en/part/1/edit']; + yield 'part new' => ['/en/part/new']; + yield 'user edit' => ['/en/user/1/edit']; + yield 'log list' => ['/en/log/']; + yield 'server info' => ['/en/tools/server_infos']; + } + + /** + * Routes the `noread` user (no group = no permissions) must be denied. + */ + public static function noreadDeniedRoutesProvider(): \Generator + { + yield 'part view' => ['/en/part/1']; + yield 'part edit' => ['/en/part/1/edit']; + yield 'part new' => ['/en/part/new']; + yield 'log list' => ['/en/log/']; + yield 'server info' => ['/en/tools/server_infos']; + yield 'select category' => ['/en/select_api/category']; + yield 'typeahead tags' => ['/en/typeahead/tags/search/test']; + } + + /** + * Routes the `user` (editor preset) must have access to. + */ + public static function editorAllowedRoutesProvider(): \Generator + { + yield 'homepage' => ['/en/']; + yield 'part view' => ['/en/part/1']; + yield 'part edit' => ['/en/part/1/edit']; + yield 'part new' => ['/en/part/new']; + yield 'select cat' => ['/en/select_api/category']; + yield 'typeahead' => ['/en/typeahead/tags/search/test']; + } + + /** + * Admin-only routes the `user` (editor) must be denied. + */ + public static function editorDeniedRoutesProvider(): \Generator + { + yield 'user edit' => ['/en/user/1/edit']; + yield 'log list' => ['/en/log/']; + yield 'server info' => ['/en/tools/server_infos']; + } + + /** + * Routes the `admin` user must be able to reach. + */ + public static function adminAllowedRoutesProvider(): \Generator + { + yield 'user edit' => ['/en/user/1/edit']; + yield 'log list' => ['/en/log/']; + yield 'server info' => ['/en/tools/server_infos']; + yield 'part view' => ['/en/part/1']; + yield 'part edit' => ['/en/part/1/edit']; + yield 'statistics' => ['/en/statistics']; + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private function loginAs(string $username): KernelBrowser + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $user = $em->getRepository(User::class)->findOneBy(['name' => $username]); + if ($user === null) { + $this->markTestSkipped("Fixture user '$username' not found."); + } + $client->loginUser($user); + $client->followRedirects(false); + return $client; + } + + private function assertDenied(KernelBrowser $client, string $url): void + { + $client->request('GET', $url); + $code = $client->getResponse()->getStatusCode(); + $this->assertTrue( + $code === Response::HTTP_FORBIDDEN || $code === Response::HTTP_UNAUTHORIZED || $client->getResponse()->isRedirect(), + "Expected 401/403/redirect on $url, got $code" + ); + } + + // ----------------------------------------------------------------------- + // Unauthenticated: public reads + // ----------------------------------------------------------------------- + + #[DataProvider('publicReadRoutesProvider')] + public function testUnauthenticatedCanReadPublicRoutes(string $url): void + { + $client = static::createClient(); + $client->request('GET', $url); + // Anonymous user (readonly group) can access read-only content + $this->assertResponseIsSuccessful(); + } + + // ----------------------------------------------------------------------- + // Unauthenticated: write routes → 401 + // ----------------------------------------------------------------------- + + #[DataProvider('writeProtectedRoutesProvider')] + public function testUnauthenticatedIsUnauthorizedOnWriteRoutes(string $url): void + { + $client = static::createClient(); + $client->followRedirects(false); + $client->request('GET', $url); + + $code = $client->getResponse()->getStatusCode(); + $this->assertTrue( + $code === Response::HTTP_UNAUTHORIZED || $client->getResponse()->isRedirect(), + "Expected 401 or redirect on $url for unauthenticated request, got $code" + ); + } + + // ----------------------------------------------------------------------- + // noread user: denied everywhere + // ----------------------------------------------------------------------- + + #[DataProvider('noreadDeniedRoutesProvider')] + public function testNoreadUserIsDenied(string $url): void + { + $this->assertDenied($this->loginAs('noread'), $url); + } + + // ----------------------------------------------------------------------- + // Editor user + // ----------------------------------------------------------------------- + + #[DataProvider('editorAllowedRoutesProvider')] + public function testEditorCanAccess(string $url): void + { + $client = $this->loginAs('user'); + $client->request('GET', $url); + $this->assertResponseIsSuccessful(); + } + + #[DataProvider('editorDeniedRoutesProvider')] + public function testEditorIsDeniedOnAdminRoutes(string $url): void + { + $this->assertDenied($this->loginAs('user'), $url); + } + + // ----------------------------------------------------------------------- + // Admin user: can access everything + // ----------------------------------------------------------------------- + + #[DataProvider('adminAllowedRoutesProvider')] + public function testAdminCanAccessAllRoutes(string $url): void + { + $client = $this->loginAs('admin'); + $client->request('GET', $url); + $this->assertResponseIsSuccessful(); + } +} diff --git a/tests/Controller/SelectApiControllerTest.php b/tests/Controller/SelectApiControllerTest.php new file mode 100644 index 00000000..b07053b9 --- /dev/null +++ b/tests/Controller/SelectApiControllerTest.php @@ -0,0 +1,152 @@ +. + */ + +namespace App\Tests\Controller; + +use App\Entity\UserSystem\User; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use Symfony\Bundle\FrameworkBundle\KernelBrowser; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +/** + * Tests the SelectAPIController endpoints used by select2 widgets. + * These JSON endpoints back every structural-entity dropdown in the UI. + */ +#[Group('DB')] +#[Group('slow')] +final class SelectApiControllerTest extends WebTestCase +{ + public static function endpointProvider(): \Generator + { + yield 'category' => ['/en/select_api/category']; + yield 'footprint' => ['/en/select_api/footprint']; + yield 'manufacturer' => ['/en/select_api/manufacturer']; + yield 'measurement_unit' => ['/en/select_api/measurement_unit']; + yield 'project' => ['/en/select_api/project']; + yield 'storage_location' => ['/en/select_api/storage_location']; + yield 'label_profiles' => ['/en/select_api/label_profiles']; + } + + private function adminClient(): KernelBrowser + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $admin = $em->getRepository(User::class)->findOneBy(['name' => 'admin']); + if ($admin === null) { + $this->markTestSkipped('Fixture user admin not found.'); + } + $client->loginUser($admin); + return $client; + } + + // ----------------------------------------------------------------------- + // Response format + // ----------------------------------------------------------------------- + + #[DataProvider('endpointProvider')] + public function testEndpointReturns200WithJsonContentType(string $url): void + { + $client = $this->adminClient(); + $client->request('GET', $url); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/json'); + } + + #[DataProvider('endpointProvider')] + public function testEndpointReturnsValidJsonArray(string $url): void + { + $client = $this->adminClient(); + $client->request('GET', $url); + + $body = $client->getResponse()->getContent(); + $decoded = json_decode($body, true); + + $this->assertIsArray($decoded, "Response from $url is not a valid JSON array"); + } + + #[DataProvider('endpointProvider')] + public function testEachEntryHasTextAndValueKeys(string $url): void + { + $client = $this->adminClient(); + $client->request('GET', $url); + + $decoded = json_decode($client->getResponse()->getContent(), true); + // Some endpoints include an empty "select none" entry at index 0; all entries must have text + value + foreach ($decoded as $entry) { + $this->assertArrayHasKey('text', $entry, "Entry in $url missing 'text' key"); + $this->assertArrayHasKey('value', $entry, "Entry in $url missing 'value' key"); + } + } + + // ----------------------------------------------------------------------- + // Access control + // ----------------------------------------------------------------------- + + #[DataProvider('endpointProvider')] + public function testUnauthenticatedCanReadSelectApi(string $url): void + { + // The anonymous user (readonly group) has read access to structural entities, + // so these endpoints return 200 even without a session. + $client = static::createClient(); + $client->request('GET', $url); + $this->assertResponseIsSuccessful(); + } + + #[DataProvider('endpointProvider')] + public function testNoreadUserIsDenied(string $url): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $noread = $em->getRepository(User::class)->findOneBy(['name' => 'noread']); + if ($noread === null) { + $this->markTestSkipped('Fixture user noread not found.'); + } + $client->loginUser($noread); + $client->followRedirects(false); + $client->request('GET', $url); + + $response = $client->getResponse(); + $this->assertTrue( + $response->getStatusCode() === 403 || $response->isRedirect(), + "Expected 403 or redirect for noread user on $url, got " . $response->getStatusCode() + ); + } + + #[DataProvider('endpointProvider')] + public function testEditorUserCanAccess(string $url): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $user = $em->getRepository(User::class)->findOneBy(['name' => 'user']); + if ($user === null) { + $this->markTestSkipped('Fixture user user not found.'); + } + $client->loginUser($user); + $client->request('GET', $url); + + $this->assertResponseIsSuccessful(); + } +} diff --git a/tests/Controller/TypeaheadControllerTest.php b/tests/Controller/TypeaheadControllerTest.php new file mode 100644 index 00000000..ce2747fa --- /dev/null +++ b/tests/Controller/TypeaheadControllerTest.php @@ -0,0 +1,162 @@ +. + */ + +namespace App\Tests\Controller; + +use App\Entity\UserSystem\User; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use Symfony\Bundle\FrameworkBundle\KernelBrowser; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +/** + * Tests the TypeaheadController JSON endpoints that back autocomplete widgets in the UI. + */ +#[Group('DB')] +#[Group('slow')] +final class TypeaheadControllerTest extends WebTestCase +{ + public static function endpointProvider(): \Generator + { + yield 'tags search' => ['/en/typeahead/tags/search/test']; + yield 'parameters part search' => ['/en/typeahead/parameters/part/search/voltage']; + yield 'parameters category search' => ['/en/typeahead/parameters/category/search/NPN']; + yield 'builtin resources' => ['/en/typeahead/builtInResources/search?query=DIP']; + yield 'parts search' => ['/en/typeahead/parts/search/res']; + } + + public static function partsReadEndpointProvider(): \Generator + { + // These require @parts.read — noread user must be denied + yield 'tags search' => ['/en/typeahead/tags/search/test']; + yield 'parameters part search' => ['/en/typeahead/parameters/part/search/voltage']; + yield 'parts search' => ['/en/typeahead/parts/search/res']; + } + + private function loginClient(string $username): KernelBrowser + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $user = $em->getRepository(User::class)->findOneBy(['name' => $username]); + if ($user === null) { + $this->markTestSkipped("Fixture user '$username' not found."); + } + $client->loginUser($user); + return $client; + } + + // ----------------------------------------------------------------------- + // Response format + // ----------------------------------------------------------------------- + + #[DataProvider('endpointProvider')] + public function testEndpointReturnsSuccessfulJsonForAdmin(string $url): void + { + $client = $this->loginClient('admin'); + $client->request('GET', $url); + + $this->assertResponseIsSuccessful(); + $this->assertJson($client->getResponse()->getContent()); + } + + #[DataProvider('endpointProvider')] + public function testEndpointReturnsJsonArray(string $url): void + { + $client = $this->loginClient('admin'); + $client->request('GET', $url); + + $decoded = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($decoded, "Response from $url should be a JSON array"); + } + + // ----------------------------------------------------------------------- + // Tags search: result structure + // ----------------------------------------------------------------------- + + public function testTagsSearchReturnsStrings(): void + { + $client = $this->loginClient('admin'); + $client->request('GET', '/en/typeahead/tags/search/a'); + + $tags = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($tags); + foreach ($tags as $tag) { + $this->assertIsString($tag, 'Each tag entry should be a plain string'); + } + } + + // ----------------------------------------------------------------------- + // Parts search: result structure + // ----------------------------------------------------------------------- + + public function testPartsSearchReturnsArrayWithExpectedKeys(): void + { + $client = $this->loginClient('admin'); + $client->request('GET', '/en/typeahead/parts/search/test'); + + $parts = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($parts); + // Each result must have at least id and name + foreach ($parts as $part) { + $this->assertArrayHasKey('id', $part); + $this->assertArrayHasKey('name', $part); + } + } + + // ----------------------------------------------------------------------- + // Access control + // ----------------------------------------------------------------------- + + #[DataProvider('endpointProvider')] + public function testUnauthenticatedCanAccessTypeahead(string $url): void + { + // Anonymous user (readonly group) has @parts.read, so these endpoints return 200. + $client = static::createClient(); + $client->request('GET', $url); + $this->assertResponseIsSuccessful(); + } + + #[DataProvider('partsReadEndpointProvider')] + public function testNoreadUserIsDenied(string $url): void + { + $client = $this->loginClient('noread'); + $client->followRedirects(false); + $client->request('GET', $url); + + $response = $client->getResponse(); + $this->assertTrue( + $response->getStatusCode() === 403 || $response->isRedirect(), + "Expected 403 or redirect for noread user on $url, got " . $response->getStatusCode() + ); + } + + #[DataProvider('endpointProvider')] + public function testEditorUserCanAccess(string $url): void + { + $client = $this->loginClient('user'); + $client->request('GET', $url); + + $this->assertResponseIsSuccessful(); + } +}