From 22c0f14e54109071c714e3eccc797cc046136fb6 Mon Sep 17 00:00:00 2001 From: Johan Cwiklinski Date: Fri, 10 Apr 2026 01:27:41 +0200 Subject: [PATCH] Invalid client id, e2e tests Also: - Get encryption key from configuration - Fix client_id detection Add e2e tests: - Test superadmin cannot connect - Test auth flow - Test expected test on invalid client_id - Work on invalids client_id - Upload logs on error - Sanitize db image name for upload --- .github/workflows/ci-linux.yml | 181 ++++++++++++- _dependencies.php | 9 +- _routes.php | 20 ++ config/config.yml.dist | 1 + lang/oauth2_en_US.po | 15 ++ lang/oauth2_fr_FR.utf8.po | 14 + .../Controllers/LoginController.php | 75 +++++- .../Middleware/Authentication.php | 28 ++ .../Repositories/ClientRepository.php | 11 + templates/default/oauth2_error.html.twig | 41 +++ .../tests/units/LoginController.php | 140 +++++++++- tests/GaletteOAuth2/GaletteOAuth2.php | 2 +- .../tests/units/ClientRepository.php | 112 ++++++++ tests/config/config.yml | 2 +- tests/e2e/README.md | 85 ++++++ tests/e2e/specs/oauth2-flow.spec.ts | 244 ++++++++++++++++++ 16 files changed, 967 insertions(+), 13 deletions(-) create mode 100644 templates/default/oauth2_error.html.twig create mode 100644 tests/GaletteOAuth2/Repositories/tests/units/ClientRepository.php create mode 100644 tests/e2e/README.md create mode 100644 tests/e2e/specs/oauth2-flow.spec.ts diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index 75c1239..60c3dc9 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -176,7 +176,13 @@ jobs: openssl genrsa -out private.key 2048 openssl rsa -in private.key -pubout -out public.key chmod 660 *.key - sed "s/KEY/$(../../vendor/bin/generate-defuse-key)/" -i encryption-key.php + DEFUSE_KEY=$(../../vendor/bin/generate-defuse-key) + cat > encryption-key.php << 'EOFKEY' + > encryption-key.php + cat encryption-key.php - name: Init for PostgreSQL env: @@ -185,7 +191,8 @@ jobs: run: | cd galette-core bin/console galette:install -v --dbtype=pgsql --dbhost=localhost --dbname=galette_tests --dbuser=galette_tests --dbpass=g@l3tte --admin=admin --password=admin --no-interaction -w - bin/console galette:plugins:install-db --all + bin/console galette:plugins:enable --all + bin/console galette:plugins:list --enabled --complete if: env.skip != 'true' && startsWith(matrix.db-image, 'postgres') - name: Init for MariaDB @@ -193,13 +200,179 @@ jobs: cd galette-core mysql -e 'create database IF NOT EXISTS galette_tests;' -u galette_tests --password=g@l3tte -h 127.0.0.1 -P 3306 bin/console galette:install -v --dbtype=mysql --dbhost=127.0.0.1 --dbname=galette_tests --dbuser=galette_tests --dbpass=g@l3tte --admin=admin --password=admin --no-interaction -w - bin/console galette:plugins:install-db --all + bin/console galette:plugins:enable --all + bin/console galette:plugins:list --enabled --complete if: env.skip != 'true' && (startsWith(matrix.db-image, 'mysql') || startsWith(matrix.db-image, 'mariadb')) - name: Unit tests if: env.skip != 'true' run: | cd galette-core/galette/webroot - php -S localhost:8888 ../plugins/plugin-oauth2/tests/router.php & + DB=${{ env.DB }} php -S localhost:8888 ../plugins/plugin-oauth2/tests/router.php & cd ../plugins/plugin-oauth2 ../../vendor/bin/phpunit --test-suffix=.php --bootstrap tests/TestsBootstrap.php --no-coverage --process-isolation tests/GaletteOAuth2/ + + - name: Sanitize ref name for artifact upload + if: failure() + id: sanitize + run: | + db_image=$(echo -n "${{ matrix.db-image }}" | sed 's/[\\\/'"'"':<>|*?]/-/g') + echo "db_image=$db_image" >> $GITHUB_OUTPUT + + - name: Upload test data (logs, photos, etc.) + uses: actions/upload-artifact@v7 + if: failure() + with: + name: test-data-${{ matrix.php-version }}-${{ steps.sanitize.outputs.db_image }} + path: | + galette-core/tests/tests-data/ + galette-core/galette/data/logs/ + galette-core/galette/data/photos/ + retention-days: 7 + if-no-files-found: ignore + + e2e-tests: + runs-on: ubuntu-latest + name: "E2E Playwright — ${{ github.event_name == 'schedule' && 'all browsers' || 'chromium' }} (postgres:17)" + env: + DB: pgsql + GALETTE_TESTS: 1 + + services: + db: + image: postgres:17 + env: + POSTGRES_USER: galette_tests + POSTGRES_PASSWORD: g@l3tte + POSTGRES_DB: galette_tests + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + steps: + - name: PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.5" + tools: composer, pecl + coverage: none + extensions: apcu, pdo_pgsql + ini-values: apc.enable_cli=1 + + - name: Build Galette + uses: galette/.github/actions/build-galette@main + with: + php-version: "8.5" + enable-assets-cache: true + + - name: Checkout plugin + uses: actions/checkout@v6 + with: + path: galette-core/galette/plugins/plugin-oauth2 + + - name: Install plugin dependencies + run: | + cd galette-core/galette/plugins/plugin-oauth2 + composer install --ignore-platform-reqs + + - name: Generate OAuth2 keys + run: | + cd galette-core/galette/plugins/plugin-oauth2 + # Generate keys for production config path (used by E2E tests) + openssl genrsa -out config/private.key 2048 + openssl rsa -in config/private.key -pubout -out config/public.key + chmod 660 config/*.key + # Generate encryption key + DEFUSE_KEY=$(vendor/bin/generate-defuse-key) + cat > config/encryption-key.php << 'EOFKEY' + > config/encryption-key.php + # Create a minimal config.yml for E2E tests + cp tests/config/config.yml config/config.yml + cat config/encryption-key.php + + - name: Initialize test data + run: | + cd galette-core + php tests/init_test_data.php + + - name: Initialize Galette database + run: | + cd galette-core + bin/console galette:install -v \ + --dbtype=pgsql \ + --dbhost=localhost \ + --dbname=galette_tests \ + --dbuser=galette_tests \ + --dbpass=g@l3tte \ + --admin=admin \ + --password=admin \ + --no-interaction + env: + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + + - name: Seed E2E fixtures + run: | + cd galette-core + bin/console galette:seed-fixtures -v + + - name: Start PHP built-in server + run: | + cd galette-core + GALETTE_PLUGINS_PATH=$(pwd)/galette/plugins DB=${{ env.DB }} GALETTE_TESTS=1 php -S 0.0.0.0:8090 -t galette/webroot tests/router_e2e.php & + timeout 15 bash -c 'until curl -sf http://127.0.0.1:8090/login > /dev/null; do sleep 1; done' + CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:8090/plugins/oauth2/authorize?response_type=code&client_id=galette_cli&redirect_uri=http://127.0.0.1:8090/plugins/oauth2/test-callback&scope=member&state=ci-check") + if [ "$CODE" = "404" ]; then + echo "OAuth2 route returned 404 (plugin not initialized/active)." + exit 1 + fi + echo "PHP server started" + + - name: Install Playwright browsers + run: | + cd galette-core + if [ "${{ github.event_name }}" == "schedule" ]; then + npx playwright install --with-deps chromium firefox + else + npx playwright install --with-deps chromium + fi + + - name: Run OAuth2 E2E tests + run: | + cd galette-core + if [ "${{ github.event_name }}" == "schedule" ]; then + npm run test:plugins:all + else + npm run test:plugins + fi + env: + E2E_BASE_URL: http://127.0.0.1:8090 + E2E_ADMIN_USER: admin + E2E_ADMIN_PASS: admin + + - name: Upload Playwright report + uses: actions/upload-artifact@v7 + if: failure() + with: + name: playwright-report-oauth2 + path: galette-core/playwright-report/ + retention-days: 7 + + - name: Upload test data (logs, photos, etc.) + uses: actions/upload-artifact@v7 + if: failure() + with: + name: test-data-e2e-oauth2 + path: | + galette-core/tests/tests-data/ + galette-core/galette/data/logs/ + galette-core/galette/data/photos/ + retention-days: 7 + if-no-files-found: ignore diff --git a/_dependencies.php b/_dependencies.php index f786e72..a71bc10 100644 --- a/_dependencies.php +++ b/_dependencies.php @@ -95,7 +95,14 @@ static function (ContainerInterface $container) { $container->set( AuthorizationServer::class, function (ContainerInterface $container) { - include OAUTH2_CONFIGPATH . '/encryption-key.php'; + $encryptionKey = $container->get(Config::class)->get('global.encryption_key', 'NONE'); + if ($encryptionKey === 'NONE' && file_exists(OAUTH2_CONFIGPATH . '/encryption-key.php')) { + include OAUTH2_CONFIGPATH . '/encryption-key.php'; + } + + if (empty($encryptionKey) || $encryptionKey === 'NONE') { + throw new RuntimeException('Encryption key not found!'); + } // Setup the authorization server $server = new AuthorizationServer( diff --git a/_routes.php b/_routes.php index 6db4f80..892f16a 100644 --- a/_routes.php +++ b/_routes.php @@ -58,6 +58,11 @@ [LoginController::class, 'logout'] )->setName(OAUTH2_PREFIX . '_logout'); +$app->get( + '/error', + [LoginController::class, 'error'] +)->setName(OAUTH2_PREFIX . '_error'); + $app->get( '/authorize', [AuthorizationController::class, 'authorize'] @@ -77,3 +82,18 @@ '/user', [ApiController::class, 'user'] )->setName(OAUTH2_PREFIX . '_user'); + +// Test callback route for E2E tests (only in test environment) +if (getenv('GALETTE_TESTS') !== false || defined('GALETTE_TESTS')) { + $app->get( + '/test-callback', + function ($request, $response) { + $params = $request->getQueryParams(); + $html = '

OAuth2 Test Callback

'; + $html .= '
' . htmlspecialchars(print_r($params, true)) . '
'; + $html .= ''; + $response->getBody()->write($html); + return $response->withHeader('Content-Type', 'text/html'); + } + )->setName(OAUTH2_PREFIX . '_test_callback'); +} diff --git a/config/config.yml.dist b/config/config.yml.dist index c7bf656..0c31f6b 100644 --- a/config/config.yml.dist +++ b/config/config.yml.dist @@ -1,6 +1,7 @@ global: title: 'Galette' password: abc123 + encryption_key: 'your-encryption-key-here' # Replace with a secure random string galette_flarum: title: 'Forum Flarum' redirect_logout: 'http://192.168.1.99/flarum/public' diff --git a/lang/oauth2_en_US.po b/lang/oauth2_en_US.po index 8013537..42dad7f 100644 --- a/lang/oauth2_en_US.po +++ b/lang/oauth2_en_US.po @@ -126,3 +126,18 @@ msgstr "Cancel" #: ../tempcache/oauth2_authorize.html.twig:146 msgid "Allow" msgstr "Allow" + +#: ../lib/GaletteOAuth2/Controllers/LoginController.php +#: ../lib/GaletteOAuth2/Middleware/Authentication.php +msgid "Unknown client application" +msgstr "Unknown client application" + +#: ../lib/GaletteOAuth2/Controllers/LoginController.php +#: ../templates/default/oauth2_error.html.twig +msgid "OAuth2 error" +msgstr "OAuth2 error" + +#: ../lib/GaletteOAuth2/Controllers/LoginController.php +msgid "An error occurred" +msgstr "An error occurred" + diff --git a/lang/oauth2_fr_FR.utf8.po b/lang/oauth2_fr_FR.utf8.po index d549cac..48d2050 100644 --- a/lang/oauth2_fr_FR.utf8.po +++ b/lang/oauth2_fr_FR.utf8.po @@ -129,6 +129,20 @@ msgstr "Annuler" msgid "Allow" msgstr "Autoriser" +#: ../lib/GaletteOAuth2/Controllers/LoginController.php +#: ../lib/GaletteOAuth2/Middleware/Authentication.php +msgid "Unknown client application" +msgstr "Application cliente inconnue" + +#: ../lib/GaletteOAuth2/Controllers/LoginController.php +#: ../templates/default/oauth2_error.html.twig +msgid "OAuth2 error" +msgstr "Erreur OAuth2" + +#: ../lib/GaletteOAuth2/Controllers/LoginController.php +msgid "An error occurred" +msgstr "Une erreur est survenue" + #, php-format #~ msgid "Please sign in for %s" #~ msgstr "Veuillez vous connecter pour %s" diff --git a/lib/GaletteOAuth2/Controllers/LoginController.php b/lib/GaletteOAuth2/Controllers/LoginController.php index 044b705..39adeca 100755 --- a/lib/GaletteOAuth2/Controllers/LoginController.php +++ b/lib/GaletteOAuth2/Controllers/LoginController.php @@ -23,11 +23,13 @@ namespace GaletteOAuth2\Controllers; +use Analog\Analog; use DI\Attribute\Inject; use DI\Container; use Galette\Controllers\AbstractPluginController; use GaletteOAuth2\Authorization\UserAuthorizationException; use GaletteOAuth2\Authorization\UserHelper; +use GaletteOAuth2\Repositories\ClientRepository; use GaletteOAuth2\Tools\Config; use GaletteOAuth2\Tools\Debug; use RKA\Session; @@ -79,11 +81,26 @@ public function login(Request $request, Response $response): Response Debug::log('GET _SESSION = ' . Debug::printVar($this->session)); } + // Validate client_id before displaying login form + $vars = $this->prepareVarsForm(); + if ($vars === null) { + return $response + ->withStatus(302) + ->withHeader( + 'Location', + $this->routeparser->urlFor( + OAUTH2_PREFIX . '_error', + [], + ['message' => _T('Unknown client application', 'oauth2')] + ) + ); + } + // display page $this->view->render( $response, $this->getTemplate(OAUTH2_PREFIX . '_login'), - $this->prepareVarsForm() + $vars ); return $response; } @@ -193,15 +210,65 @@ public function logout(Request $request, Response $response): Response return $response->withHeader('Location', $redirect_logout)->withStatus(302); } - private function prepareVarsForm() + /** + * Display error page + * + * @param Request $request Received request + * @param Response $response Response instance + */ + public function error(Request $request, Response $response): Response + { + Debug::logRequest('error()', $request); + + $error_message = $request->getQueryParams()['message'] ?? _T('An error occurred', 'oauth2'); + + $this->view->render( + $response, + $this->getTemplate(OAUTH2_PREFIX . '_error'), + [ + 'page_title' => _T('OAuth2 error', 'oauth2'), + 'error_message' => $error_message + ] + ); + return $response; + } + + private function prepareVarsForm(): ?array { - $client_id = $this->session->request_args['client_id']; + $client_id = $this->session->request_args['client_id'] ?? null; + + // Validate client_id exists + if ($client_id === null || $client_id === '') { + Analog::log( + sprintf( + 'OAuth2: Missing client_id in request from IP %s', + $_SERVER['REMOTE_ADDR'] ?? 'unknown' + ), + Analog::WARNING + ); + return null; + } + + // Check if client exists in configuration + $clientRepository = new ClientRepository($this->container); + if (!$clientRepository->clientExists($client_id)) { + Analog::log( + sprintf( + 'OAuth2: Invalid client_id "%s" in request from IP %s', + $client_id, + $_SERVER['REMOTE_ADDR'] ?? 'unknown' + ), + Analog::WARNING + ); + return null; + } + $server_title = $this->config->get('global.title', 'Galette'); $sign_in_with = sprintf( _T('Sign in with %s', 'oauth2'), $server_title ); - $application = $this->config->get("{$client_id}.title", 'noname'); + $application = $this->config->get("{$client_id}.title", ''); $page_title = sprintf( _T('Sign in %s', 'oauth2'), $application diff --git a/lib/GaletteOAuth2/Middleware/Authentication.php b/lib/GaletteOAuth2/Middleware/Authentication.php index ba6eadc..e70b231 100755 --- a/lib/GaletteOAuth2/Middleware/Authentication.php +++ b/lib/GaletteOAuth2/Middleware/Authentication.php @@ -23,6 +23,8 @@ namespace GaletteOAuth2\Middleware; +use Analog\Analog; +use GaletteOAuth2\Repositories\ClientRepository; use GaletteOAuth2\Tools\Debug; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ResponseInterface as Response; @@ -39,11 +41,13 @@ */ final class Authentication { + private Container $container; private RouteParser $routeparser; private Session $session; public function __construct(Container $container) { + $this->container = $container; $this->routeparser = $container->get(RouteParser::class); $this->session = $container->get('oauth_session'); } @@ -56,6 +60,30 @@ public function __construct(Container $container) */ public function __invoke(Request $request, RequestHandler $handler): Response { + // Validate client_id before proceeding + $queryParams = $request->getQueryParams(); + $client_id = $queryParams['client_id'] ?? null; + + $clientRepository = new ClientRepository($this->container); + if (!$clientRepository->clientExists($client_id)) { + Analog::log( + sprintf( + 'OAuth2: Invalid or missing client_id "%s" in authorization request from IP %s', + $client_id ?? 'null', + $_SERVER['REMOTE_ADDR'] ?? 'unknown' + ), + Analog::WARNING + ); + + $response = new \Slim\Psr7\Response(); + $url = $this->routeparser->urlFor( + OAUTH2_PREFIX . '_error', + [], + ['message' => _T('Unknown client application', 'oauth2')] + ); + return $response->withHeader('Location', $url)->withStatus(302); + } + $loggedIn = $this->session->isLoggedIn ?? ''; if ('yes' !== $loggedIn) { diff --git a/lib/GaletteOAuth2/Repositories/ClientRepository.php b/lib/GaletteOAuth2/Repositories/ClientRepository.php index 54c8652..1084f6e 100755 --- a/lib/GaletteOAuth2/Repositories/ClientRepository.php +++ b/lib/GaletteOAuth2/Repositories/ClientRepository.php @@ -50,6 +50,17 @@ public function __construct(Container $container) $this->session = $this->container->get('oauth_session'); } + /** + * Check if a client exists in the configuration + */ + public function clientExists(?string $client_id): bool + { + if (empty($client_id)) { + return false; + } + return $this->config->get($client_id) !== ''; + } + public function getClientEntity($client_id): ClientEntityInterface { $client = new ClientEntity(); diff --git a/templates/default/oauth2_error.html.twig b/templates/default/oauth2_error.html.twig new file mode 100644 index 0000000..97292ce --- /dev/null +++ b/templates/default/oauth2_error.html.twig @@ -0,0 +1,41 @@ +{# +/** + * Copyright © 2021-2026 The Galette Team + * + * This file is part of Galette OAuth2 plugin (https://galette-community.github.io/plugin-oauth2/). + * + * Galette is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Galette is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Galette OAuth2 plugin. If not, see . + */ +#} +{% extends "public_page.html.twig" %} + +{% set ext_auth = true %} + +{% block content %} +
+
+ + {{ _T("OAuth2 error", "oauth2") }} +
+

{{ error_message }}

+
+ + +{% endblock %} + diff --git a/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php b/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php index cb521ed..86c7611 100644 --- a/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php +++ b/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php @@ -21,7 +21,9 @@ namespace GaletteOAuth2\Controllers\tests\units; +use Analog\Analog; use Galette\Tests\GaletteRoutingTestCase; +use PHPUnit\Framework\Attributes\DataProvider; /** * Login controller tests @@ -47,6 +49,20 @@ public function setUp(): void $session = $this->session; } + /** + * Data provider for invalid client IDs + * + * @return array> + */ + public static function invalidClientIdsProvider(): array + { + return [ + 'unknown client' => ['unknown_client'], + 'galette_unknown' => ['galette_unknown'], + 'empty string' => [''], + ]; + } + /** * Test display login page * @@ -122,7 +138,7 @@ public function testDoLogin(): void $this->assertSame(['Location' => [$this->routeparser->urlFor(OAUTH2_PREFIX . '_login')]], $test_response->getHeaders()); $this->assertSame(301, $test_response->getStatusCode()); $this->expectLogEntry( - \Analog::WARNING, + Analog::WARNING, 'No entry found for login `jdoe`' ); //flash data are stored in plugin specific session... No way from tests to check them, including on redirected page @@ -135,9 +151,129 @@ public function testDoLogin(): void $this->assertSame(['Location' => [$this->routeparser->urlFor(OAUTH2_PREFIX . '_login')]], $test_response->getHeaders()); $this->assertSame(301, $test_response->getStatusCode()); $this->expectLogEntry( - \Analog::WARNING, + Analog::WARNING, 'OAuth login attempt from superadmin account' ); //flash data are stored in plugin specific session... No way from tests to check them, including on redirected page } + + /** + * Test login with invalid client_id redirects to error page + * + * @param string $client_id Invalid client ID to test + * @return void + */ + #[DataProvider('invalidClientIdsProvider')] + public function testLoginWithInvalidClientId(string $client_id): void + { + $route_name = OAUTH2_PREFIX . '_login'; + $query_params = [ + 'redirect_url' => $this->routeparser->urlFor( + OAUTH2_PREFIX . '_authorize', + [], + [ + 'scope' => 'member', + 'state' => '7d627422092a7a5ac413ac597312b9b4', + 'response_type' => 'code', + 'approval_prompt' => 'auto', + 'redirect_uri' => 'http://example.com/auth', + 'client_id' => $client_id + ] + ) + ]; + $request = $this->createRequest( + route_name: $route_name, + query_params: $query_params + ); + + $test_response = $this->app->handle($request); + + // Should redirect to error page + $this->assertSame(302, $test_response->getStatusCode()); + $location = $test_response->getHeader('Location')[0] ?? ''; + $this->assertStringContainsString(OAUTH2_PREFIX . '/error', $location); + + // Should log a warning with the invalid client_id + if ($client_id === '') { + $this->expectLogEntry( + Analog::WARNING, + 'OAuth2: Missing client_id in request' + ); + } else { + $this->expectLogEntry( + Analog::WARNING, + 'OAuth2: Invalid client_id "' . $client_id . '"' + ); + } + } + + /** + * Test login without redirect_url (no client_id in session) + * + * @return void + */ + public function testLoginWithoutRedirectUrl(): void + { + $route_name = OAUTH2_PREFIX . '_login'; + $request = $this->createRequest( + route_name: $route_name, + query_params: [] + ); + + $test_response = $this->app->handle($request); + + // Should redirect to error page because no client_id + $this->assertSame(302, $test_response->getStatusCode()); + $location = $test_response->getHeader('Location')[0] ?? ''; + $this->assertStringContainsString(OAUTH2_PREFIX . '/error', $location); + + $this->expectLogEntry( + Analog::WARNING, + 'OAuth2: Missing client_id in request' + ); + } + + /** + * Test error page displays correctly + * + * @return void + */ + public function testErrorPage(): void + { + $route_name = OAUTH2_PREFIX . '_error'; + $query_params = [ + 'message' => 'Test error message' + ]; + $request = $this->createRequest( + route_name: $route_name, + query_params: $query_params + ); + + $test_response = $this->app->handle($request); + $this->expectOK($test_response); + + $body = (string)$test_response->getBody(); + $this->assertStringContainsString('Test error message', $body); + $this->assertStringContainsString('OAuth2 error', $body); + } + + /** + * Test error page with default message + * + * @return void + */ + public function testErrorPageDefaultMessage(): void + { + $route_name = OAUTH2_PREFIX . '_error'; + $request = $this->createRequest( + route_name: $route_name, + query_params: [] + ); + + $test_response = $this->app->handle($request); + $this->expectOK($test_response); + + $body = (string)$test_response->getBody(); + $this->assertStringContainsString('OAuth2 error', $body); + } } diff --git a/tests/GaletteOAuth2/GaletteOAuth2.php b/tests/GaletteOAuth2/GaletteOAuth2.php index b67f4c3..145c195 100644 --- a/tests/GaletteOAuth2/GaletteOAuth2.php +++ b/tests/GaletteOAuth2/GaletteOAuth2.php @@ -64,7 +64,7 @@ public function testFlow(): void $provider = new \Galette\OAuth2\Client\Provider\Galette([ //information related to the app where you will use galette-oauth2 'clientId' => 'galette_cli', // The client ID assigned to you - 'clientSecret' => '4567zyx', // The client password assigned to you + 'clientSecret' => 'abc123', // The client password assigned to you 'redirectUri' => 'http://localhost:8888', // The return URL you specified for your app //information related to the galette instance you want to connect to 'instance' => 'http://localhost:8888', // The instance of Galette you want to connect to diff --git a/tests/GaletteOAuth2/Repositories/tests/units/ClientRepository.php b/tests/GaletteOAuth2/Repositories/tests/units/ClientRepository.php new file mode 100644 index 0000000..0832d81 --- /dev/null +++ b/tests/GaletteOAuth2/Repositories/tests/units/ClientRepository.php @@ -0,0 +1,112 @@ +. + */ + +namespace GaletteOAuth2\Repositories\tests\units; + +use Galette\Tests\GaletteTestCase; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * ClientRepository tests + * + * @author Johan Cwiklinski + */ +class ClientRepository extends GaletteTestCase +{ + protected int $seed = 20260413100000; + protected bool $load_plugins = true; + + /** + * Set up tests + * + * @return void + */ + public function setUp(): void + { + global $session; + + parent::setUp(); + $this->session = $this->container->get('oauth_session'); + $session = $this->session; + } + + /** + * Data provider for valid client IDs + * + * @return array> + */ + public static function validClientIdsProvider(): array + { + return [ + 'galette_flarum' => ['galette_flarum'], + 'galette_nc' => ['galette_nc'], + 'galette_cli' => ['galette_cli'], + ]; + } + + /** + * Data provider for invalid client IDs + * + * @return array> + */ + public static function invalidClientIdsProvider(): array + { + return [ + 'null' => [null], + 'empty string' => [''], + 'unknown client' => ['unknown_client'], + 'galette_unknown' => ['galette_unknown'], + 'random string' => ['some_random_string'], + ]; + } + + /** + * Test clientExists with valid client IDs + * + * @param string $client_id Client ID to test + * @return void + */ + #[DataProvider('validClientIdsProvider')] + public function testClientExistsWithValidClients(string $client_id): void + { + $clientRepository = new \GaletteOAuth2\Repositories\ClientRepository($this->container); + $this->assertTrue( + $clientRepository->clientExists($client_id), + "Client '$client_id' should exist in configuration" + ); + } + + /** + * Test clientExists with invalid client IDs + * + * @param string|null $client_id Client ID to test + * @return void + */ + #[DataProvider('invalidClientIdsProvider')] + public function testClientExistsWithInvalidClients(?string $client_id): void + { + $clientRepository = new \GaletteOAuth2\Repositories\ClientRepository($this->container); + $this->assertFalse( + $clientRepository->clientExists($client_id), + "Client '$client_id' should not exist in configuration" + ); + } +} diff --git a/tests/config/config.yml b/tests/config/config.yml index e9d3abf..0362614 100644 --- a/tests/config/config.yml +++ b/tests/config/config.yml @@ -14,7 +14,7 @@ galette_nc: - member:phones - member:groups galette_cli: - password: 4567zyx + password: abc123 title: CLI for testing redirect_logout: 'http://localhost' authorize: teamonly diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..28cbc09 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,85 @@ +# OAuth2 Plugin E2E Tests + +## Prerequisites + +- Galette installed and configured +- Test database initialized (`bin/console galette:install`), fixture data added +- Playwright installed (`npm install` from Galette root) + +You can find useful information on Playwright setup in the `tests/e2e/README.md` file from Galette. + +## Running tests + +### From Galette root directory: + +```bash +# Run OAuth2 E2E tests only +npx playwright test galette/plugins/plugin-oauth2/tests/e2e/specs/ + +# Run with visible browser (headed mode) +npx playwright test galette/plugins/plugin-oauth2/tests/e2e/specs/ --headed + +# Debug mode (step-by-step) +npx playwright test galette/plugins/plugin-oauth2/tests/e2e/specs/ --debug + +# UI mode +npx playwright test galette/plugins/plugin-oauth2/tests/e2e/specs/ --ui + +# Run specific test +npx playwright test galette/plugins/plugin-oauth2/tests/e2e/specs/oauth2-flow.spec.ts +``` + +### Start test server manually: + +```bash +# Terminal 1: Start PHP server +cd /path/to/galette +DB=pgsql GALETTE_TESTS=1 php -S 0.0.0.0:8090 -t galette/webroot tests/router_e2e.php + +# Terminal 2: Run tests +E2E_BASE_URL=http://127.0.0.1:8090 npx playwright test galette/plugins/plugin-oauth2/tests/e2e/specs/ +``` + +## Using Galette shared fixtures + +Plugin specs can import Galette E2E fixtures via the `@e2e` alias defined in +`tsconfig.json`: + +```typescript +import { test as base, expect } from '@playwright/test'; +import { test } from '@e2e/fixtures/auth.fixture'; + +// Use `base(...)` for tests with a plain page (no auth) +base('my test', async ({ page }) => { ... }); + +// Use `test(...)` for tests with a pre-authenticated page +test('my test', async ({ loggedInPage }) => { ... }); +``` + +Available fixtures: +- **`@e2e/fixtures/auth.fixture`** — provides `loggedInPage` (logged in as admin) +- **`@e2e/fixtures/a11y.fixture`** — provides `axeBuilder()` and `formatViolations()` for accessibility audits + +## Test Coverage + +The E2E tests cover: + +1. **Complete OAuth2 Authorization Code Flow** + - Client redirects user to Galette OAuth2 + - User logs in on OAuth2 login page + - User approves authorization + - Client receives authorization code + - Client exchanges code for access token + - Client retrieves user information with access token + +2. **Error Handling** + - Invalid client_id shows error + +3. **UI Validation** + - OAuth2 login page displays correctly + - All form elements are visible + +## Notes + +- Tests require data to be committed to database +- Screenshots and traces are captured on failure diff --git a/tests/e2e/specs/oauth2-flow.spec.ts b/tests/e2e/specs/oauth2-flow.spec.ts new file mode 100644 index 0000000..01dd246 --- /dev/null +++ b/tests/e2e/specs/oauth2-flow.spec.ts @@ -0,0 +1,244 @@ +/*! + * Copyright © 2021-2026 The Galette Team + * + * This file is part of Galette OAuth2 plugin (https://galette-community.github.io/plugin-oauth2/). + * + * Galette is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Galette is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Galette OAuth2 plugin. If not, see . + * + * @author Johan Cwiklinski + */ + +import { test as base, expect } from '@playwright/test'; +import { test } from '@e2e/fixtures/auth.fixture'; + +/** + * OAuth2 Flow E2E Tests + * + * These tests verify the complete OAuth2 authorization code flow: + * 1. Client initiates OAuth2 authorization + * 2. User is redirected to Galette OAuth2 login page + * 3. User authenticates + * 4. User approves the authorization request + * 5. User is redirected back to client with authorization code + * 6. Client exchanges code for access token + * 7. Client uses access token to get user information + * + * Galette shared fixtures are available via the @e2e alias: + * import { test } from '@e2e/fixtures/auth.fixture'; + * test('my test', async ({ loggedInPage }) => { ... }); + */ +test.describe('OAuth2 Plugin', () => { + + test.describe('Login Page UI', () => { + + base('OAuth2 login page displays correctly', async ({ page }) => { + const baseUrl = process.env.E2E_BASE_URL || 'http://127.0.0.1:8090'; + const clientId = 'galette_cli'; + + const authParams = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: `${baseUrl}/callback`, + scope: 'member', + state: 'test-ui', + }); + const authorizationUrl = `${baseUrl}/plugins/oauth2/authorize?${authParams.toString()}`; + + await page.goto(authorizationUrl); + + // Should redirect to OAuth2 login page + await expect(page).toHaveURL(/\/plugins\/oauth2\/login/); + + // Check page elements + await expect(page.locator('input[name="login"]')).toBeVisible(); + await expect(page.locator('input[name="password"]')).toBeVisible(); + await expect(page.locator('button[type="submit"], input[type="submit"]')).toBeVisible(); + + // Page should have a title + await expect(page).toHaveTitle(/.+/); + }); + + }); + + test.describe('Authorization Flow', () => { + + base('OAuth2 flow superadmin cannot login', async ({ page }) => { + const baseUrl = process.env.E2E_BASE_URL || 'http://127.0.0.1:8090'; + const clientId = 'galette_cli'; + const redirectUri = `${baseUrl}/oauth2-test-callback`; + const scope = 'member member:localization'; + + // Step 1: Build authorization URL (simulating client application) + const state = 'test-state-' + Date.now(); + const authParams = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope: scope, + state: state, + }); + const authorizationUrl = `${baseUrl}/plugins/oauth2/authorize?${authParams.toString()}`; + + // Step 2: Navigate to authorization URL + await page.goto(authorizationUrl); + + // Should be redirected to login page with redirect_url + await expect(page).toHaveURL(/\/plugins\/oauth2\/login.*redirect_url=/); + await expect(page.locator('input[name="login"]')).toBeVisible(); + await expect(page.locator('input[name="password"]')).toBeVisible(); + + // Step 3: Fill login form + const login = 'admin'; + const password = 'admin'; + + await page.locator('input[name="login"]').fill(login); + await page.locator('input[name="password"]').fill(password); + await page.locator('button[type="submit"], input[type="submit"]').click(); + + // Step 4: Should be redirected to login page + await expect(page).toHaveURL(/\/plugins\/oauth2\/login/); + + // Wait for the error toast to appear. It can take some time in CI. + await expect(page.locator('.ui.toast.error')).toBeVisible({ timeout: 10000 }); + }); + + base('Complete OAuth2 authorization code flow', async ({ page }) => { + const baseUrl = process.env.E2E_BASE_URL || 'http://127.0.0.1:8090'; + const clientId = 'galette_cli'; + const redirectUri = `${baseUrl}/plugins/oauth2/test-callback`; + const scope = 'member member:localization'; + + // Step 1: Build authorization URL (simulating client application) + const state = 'test-state-' + Date.now(); + const authParams = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope: scope, + state: state, + }); + const authorizationUrl = `${baseUrl}/plugins/oauth2/authorize?${authParams.toString()}`; + + // Step 2: Navigate to authorization URL + await page.goto(authorizationUrl); + + // Should be redirected to login page with redirect_url + await expect(page).toHaveURL(/\/plugins\/oauth2\/login.*redirect_url=/); + await expect(page.locator('input[name="login"]')).toBeVisible(); + await expect(page.locator('input[name="password"]')).toBeVisible(); + + // Step 3: Fill login form + const login = 'leia.organa'; + const password = 'G@l3tte-E2E!'; + + await page.locator('input[name="login"]').fill(login); + await page.locator('input[name="password"]').fill(password); + await page.locator('button[type="submit"], input[type="submit"]').click(); + + // Step 4: Should be redirected to authorization page + await expect(page).toHaveURL(/\/plugins\/oauth2\/authorize/); + + // Check that authorization page displays correctly + await expect(page.locator('button[name="approve"], input[name="approve"]')).toBeVisible({ timeout: 10000 }); + + // Step 5: Approve the authorization + await page.locator('button[name="approve"], input[name="approve"]').click(); + + // Step 6: Should be redirected to test callback with code + await page.waitForURL(/\/plugins\/oauth2\/test-callback/, { timeout: 10000 }); + + // Verify the URL contains the authorization code and state + const finalUrl = page.url(); + const urlParams = new URLSearchParams(new URL(finalUrl).search); + + expect(urlParams.has('code')).toBeTruthy(); + expect(urlParams.has('state')).toBeTruthy(); + expect(urlParams.get('state')).toBe(state); + + const authorizationCode = urlParams.get('code'); + expect(authorizationCode).toBeTruthy(); + expect(authorizationCode!.length).toBeGreaterThan(10); + + // Step 7: Exchange code for access token (API call) + const tokenResponse = await page.request.post(`${baseUrl}/plugins/oauth2/access_token`, { + form: { + grant_type: 'authorization_code', + code: authorizationCode!, + redirect_uri: redirectUri, + client_id: clientId, + client_secret: 'abc123', + }, + }); + + // Debug: log response if not OK + if (!tokenResponse.ok()) { + console.error('Token exchange failed:', tokenResponse.status(), await tokenResponse.text()); + } + expect(tokenResponse.ok()).toBeTruthy(); + const tokenData = await tokenResponse.json(); + + expect(tokenData).toHaveProperty('access_token'); + expect(tokenData).toHaveProperty('token_type'); + expect(tokenData.token_type).toBe('Bearer'); + expect(tokenData).toHaveProperty('expires_in'); + + // Step 8: Use access token to get user info + const userInfoResponse = await page.request.get(`${baseUrl}/plugins/oauth2/user`, { + headers: { + 'Authorization': `Bearer ${tokenData.access_token}`, + }, + }); + + // Debug: log response if not OK + if (!userInfoResponse.ok()) { + console.error('User info failed:', userInfoResponse.status(), await userInfoResponse.text()); + } + expect(userInfoResponse.ok()).toBeTruthy(); + const userInfo = await userInfoResponse.json(); + + // Verify user information + expect(userInfo).toHaveProperty('id'); + expect(userInfo).toHaveProperty('username'); + expect(userInfo.username).toBe(login); + }); + + base('Shows error for invalid client_id', async ({ page }) => { + const baseUrl = process.env.E2E_BASE_URL || 'http://127.0.0.1:8090'; + + const authParams = new URLSearchParams({ + response_type: 'code', + client_id: 'invalid_client_does_not_exist', + redirect_uri: `${baseUrl}/callback`, + scope: 'member', + state: 'test-invalid', + }); + const authorizationUrl = `${baseUrl}/plugins/oauth2/authorize?${authParams.toString()}`; + + await page.goto(authorizationUrl); + + // Should show an error + await page.waitForTimeout(2000); // Give time for error to appear + + // Wait for the error to appear. It can take some time in CI. + await expect(page.locator('.ui.red.message')).toBeVisible({ timeout: 10000 }); + await page.getByText('OAuth2 error Unknown client'); + const hasErrorOnPage = await page.locator('.ui.red.message').isVisible().catch(() => false); + + expect(hasErrorOnPage).toBeTruthy(); + }); + + }); + +}); +