From 1cdc1b24a15720046231b48d8675ace89c52641d Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Wed, 22 Jul 2026 23:47:02 -0400 Subject: [PATCH] feat: implement console command Signed-off-by: Sebastian Krupinski --- .github/workflows/integration-tests.yml | 52 +++++ .gitignore | 1 + composer.json | 3 + lib/Controllers/PasswordController.php | 28 ++- tests/php/Integration/IntegrationTestCase.php | 211 ++++++++++++++++++ .../PasswordAuthenticationTest.php | 80 +++++++ .../Integration/PasswordControllerTest.php | 126 +++++++++++ tests/php/bootstrap.php | 16 ++ tests/php/phpunit.integration.xml | 27 +++ 9 files changed, 540 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/integration-tests.yml create mode 100644 tests/php/Integration/IntegrationTestCase.php create mode 100644 tests/php/Integration/PasswordAuthenticationTest.php create mode 100644 tests/php/Integration/PasswordControllerTest.php create mode 100644 tests/php/bootstrap.php create mode 100644 tests/php/phpunit.integration.xml diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..5a32e51 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,52 @@ +name: Integration Tests + +on: + pull_request: + workflow_dispatch: + +jobs: + test: + name: Integration Tests + runs-on: ubuntu-latest + services: + mongo: + image: mongo:7 + ports: + - 27017:27017 + + steps: + - name: Retrieve Server Install Action + uses: actions/checkout@v6.0.2 + with: + repository: Nodarx/action-server-install + ref: main + path: action-server-install + github-server-url: https://git.ktrix.dev + + - name: Install server + uses: ./action-server-install + with: + install-php: 'true' + php-version: '8.5' + server-path: './server' + database-uri: 'mongodb://127.0.0.1:27017/?tls=false' + database-name: 'ktrix_ci' + app-environment: 'test' + + - name: Checkout module under test + uses: actions/checkout@v6.0.2 + with: + repository: ${{ github.repository }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} + path: server/modules/authentication_provider_password + github-server-url: https://git.ktrix.dev + + - name: Install and enable module + working-directory: server + run: | + php bin/console module:install authentication_provider_password + php bin/console module:enable authentication_provider_password + + - name: Run integration tests + working-directory: server/modules/authentication_provider_password + run: composer test:integration diff --git a/.gitignore b/.gitignore index 17ef598..3660791 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ node_modules/ /lib/vendor/ coverage/ phpunit.xml.cache +.phpunit.cache/ .phpunit.result.cache .php-cs-fixer.cache .phpstan.cache diff --git a/composer.json b/composer.json index dfcbedf..0e9498b 100644 --- a/composer.json +++ b/composer.json @@ -9,5 +9,8 @@ }, "require": { "php": ">=8.2" + }, + "scripts": { + "test:integration": "../../vendor/bin/phpunit --configuration tests/php/phpunit.integration.xml --colors=always --testdox" } } diff --git a/lib/Controllers/PasswordController.php b/lib/Controllers/PasswordController.php index 2e3d631..b382814 100644 --- a/lib/Controllers/PasswordController.php +++ b/lib/Controllers/PasswordController.php @@ -3,6 +3,7 @@ namespace KTXM\AuthenticationProviderPassword\Controllers; use KTXC\Http\Response\JsonResponse; +use KTXC\Service\UserAccountsService; use KTXC\SessionIdentity; use KTXC\SessionTenant; use KTXF\Controller\ControllerAbstract; @@ -18,7 +19,8 @@ class PasswordController extends ControllerAbstract private readonly SessionTenant $sessionTenant, private readonly CredentialStore $credentialStore, private readonly Provider $provider, - private readonly Crypto $crypto + private readonly Crypto $crypto, + private readonly UserAccountsService $userAccountsService ) { } @@ -62,7 +64,13 @@ class PasswordController extends ControllerAbstract // TODO: Add permission check for admin operations - $hasCredentials = $this->provider->hasCredentials($tenantId, $uid); + $user = $this->userAccountsService->fetchByIdentifier($uid); + if (!$user) { + return new JsonResponse(['error' => 'User not found'], 404); + } + + // Credentials are keyed by identity (email/username), not uid + $hasCredentials = $this->provider->hasCredentials($tenantId, $user['identity']); return new JsonResponse([ 'enrolled' => $hasCredentials, @@ -88,7 +96,13 @@ class PasswordController extends ControllerAbstract return new JsonResponse(['error' => 'Password must be at least 8 characters'], 400); } - $success = $this->provider->setCredential($tenantId, $uid, $password); + $user = $this->userAccountsService->fetchByIdentifier($uid); + if (!$user) { + return new JsonResponse(['error' => 'User not found'], 404); + } + + // Credentials are keyed by identity (email/username), not uid + $success = $this->provider->setCredential($tenantId, $user['identity'], $password); if (!$success) { return new JsonResponse(['error' => 'Failed to set password'], 500); @@ -111,7 +125,13 @@ class PasswordController extends ControllerAbstract // TODO: Add permission check for admin operations - $this->credentialStore->delete($tenantId, $uid); + $user = $this->userAccountsService->fetchByIdentifier($uid); + if (!$user) { + return new JsonResponse(['error' => 'User not found'], 404); + } + + // Credentials are keyed by identity (email/username), not uid + $this->credentialStore->delete($tenantId, $user['identity']); return new JsonResponse(['success' => true]); } diff --git a/tests/php/Integration/IntegrationTestCase.php b/tests/php/Integration/IntegrationTestCase.php new file mode 100644 index 0000000..193315b --- /dev/null +++ b/tests/php/Integration/IntegrationTestCase.php @@ -0,0 +1,211 @@ +environment() === 'prod') { + self::fail('Refusing to run integration tests against a server configured for the "prod" environment.'); + } + + static::$tenantDomain = 'ci-' . bin2hex(random_bytes(4)) . '.test'; + static::$tenantIdentifier = 'ci_' . bin2hex(random_bytes(4)); + + static::runConsole([ + 'tenant:create', static::$tenantDomain, + '--identifier', static::$tenantIdentifier, + ]); + static::runConsole(['tenant:auth:enable', static::$tenantIdentifier, 'password']); + } + + public static function tearDownAfterClass(): void + { + if (isset(static::$tenantIdentifier)) { + static::runConsole(['tenant:delete', static::$tenantIdentifier, '--force']); + } + } + + protected static function buildServer(): Server + { + return new Server(KTRIX_SERVER_ROOT); + } + + // ========================================================================= + // Provisioning helpers (run through bin/console, same as CI/ops would) + // ========================================================================= + + /** + * Provision a user (optionally with a password credential) and return + * its uid, so tests can exercise admin endpoints that operate by uid. + */ + protected static function createUser(string $identity, ?string $password = null): string + { + $uid = bin2hex(random_bytes(8)); + + static::runConsole(['user:create', static::$tenantIdentifier, $identity, '--uid', $uid]); + + if ($password !== null) { + static::runConsole(['user:password', static::$tenantIdentifier, $identity, $password]); + } + + return $uid; + } + + /** + * Run a bin/console command as a real subprocess and fail the test on + * a non-zero exit code. + * + * @param string[] $args Command name followed by its arguments/options + */ + protected static function runConsole(array $args): string + { + $command = escapeshellcmd(PHP_BINARY) . ' ' . escapeshellarg(KTRIX_SERVER_ROOT . '/bin/console'); + foreach ($args as $arg) { + $command .= ' ' . escapeshellarg((string) $arg); + } + $command .= ' --no-interaction 2>&1'; + + exec($command, $outputLines, $exitCode); + $output = implode("\n", $outputLines); + + if ($exitCode !== 0) { + static::fail("Console command failed (exit {$exitCode}): {$command}\n{$output}"); + } + + return $output; + } + + // ========================================================================= + // HTTP helpers (dispatched in-process through the real kernel) + // ========================================================================= + + /** + * Dispatch a request through the real application and return the + * decoded JSON body alongside the status code. + * + * @return array{status: int, body: mixed, response: Response} + */ + protected static function request(string $method, string $path, array $data = [], array $headers = []): array + { + $server = [ + 'HTTP_HOST' => static::$tenantDomain, + 'SERVER_NAME' => static::$tenantDomain, + 'REMOTE_ADDR' => '127.0.0.1', + ]; + + foreach ($headers as $name => $value) { + $server['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value; + } + + $content = null; + if (!empty($data) && $method !== 'GET') { + $content = json_encode($data, JSON_THROW_ON_ERROR); + $server['CONTENT_TYPE'] = 'application/json'; + } + + $uri = 'http://' . static::$tenantDomain . $path; + $request = Request::create($uri, $method, [], [], [], $server, $content); + + // Kernel::boot() registers a global error/exception handler per + // instance and never removes it (fine under one-process-per-request + // in production; here it would otherwise stack a handler per test). + // Pop back to whatever was in place before this request. + try { + $response = static::buildServer()->handle($request); + } finally { + restore_error_handler(); + restore_exception_handler(); + } + + $body = $response->getContent(); + $decoded = null; + if (is_string($body) && $body !== '') { + $decoded = json_decode($body, true); + } + + return ['status' => $response->getStatusCode(), 'body' => $decoded, 'response' => $response]; + } + + protected static function authorizedRequest(string $token, string $method, string $path, array $data = []): array + { + return static::request($method, $path, $data, ['Authorization' => 'Bearer ' . $token]); + } + + /** + * Read a cookie's value off a Response. Authentication issues its + * access/refresh JWTs exclusively as Set-Cookie headers (accessToken, + * refreshToken) — AuthenticationResponse::toArray() never puts them in + * the JSON body — so tests read the token straight off the Response + * object we already have in-process, the equivalent of a browser + * reading Set-Cookie. + */ + protected static function cookieValue(Response $response, string $name): ?string + { + foreach ($response->headers->getCookies() as $cookie) { + if ($cookie->getName() === $name) { + return $cookie->getValue(); + } + } + + return null; + } + + /** + * Drive the real /auth/start -> /auth/identify -> /auth/verify flow for + * the password method. + * + * @return array{status: int, body: mixed, response: Response, accessToken: ?string, refreshToken: ?string} + * The /auth/verify response, with tokens (if any) pulled from Set-Cookie. + */ + protected static function loginWithPassword(string $identity, string $password): array + { + $start = static::request('GET', '/auth/start'); + $session = $start['body']['session'] ?? null; + static::assertNotNull($session, 'auth/start did not return a session id: ' . json_encode($start)); + + static::request('POST', '/auth/identify', ['session' => $session, 'identity' => $identity]); + + $verify = static::request('POST', '/auth/verify', [ + 'session' => $session, + 'method' => 'password', + 'response' => $password, + ]); + + $verify['accessToken'] = static::cookieValue($verify['response'], 'accessToken'); + $verify['refreshToken'] = static::cookieValue($verify['response'], 'refreshToken'); + + return $verify; + } +} diff --git a/tests/php/Integration/PasswordAuthenticationTest.php b/tests/php/Integration/PasswordAuthenticationTest.php new file mode 100644 index 0000000..58c35e8 --- /dev/null +++ b/tests/php/Integration/PasswordAuthenticationTest.php @@ -0,0 +1,80 @@ + /auth/identify -> /auth/verify flow for + * the password provider, through the HTTP kernel end to end. + */ +class PasswordAuthenticationTest extends IntegrationTestCase +{ + public function testAuthStartOffersPasswordMethod(): void + { + $start = static::request('GET', '/auth/start'); + + $this->assertSame(200, $start['status']); + $methodIds = array_column($start['body']['methods'] ?? [], 'id'); + $this->assertContains('password', $methodIds); + } + + public function testSuccessfulLoginReturnsTokens(): void + { + static::createUser('alice@example.test', 'Correct-Horse-1!'); + + $result = static::loginWithPassword('alice@example.test', 'Correct-Horse-1!'); + + $this->assertSame('success', $result['body']['status'] ?? null, json_encode($result['body'])); + $this->assertNotNull($result['accessToken']); + $this->assertNotNull($result['refreshToken']); + $this->assertSame('alice@example.test', $result['body']['user']['identity'] ?? null); + } + + public function testAccessTokenAuthorizesSubsequentRequests(): void + { + static::createUser('bob@example.test', 'Correct-Horse-2!'); + $login = static::loginWithPassword('bob@example.test', 'Correct-Horse-2!'); + + $ping = static::authorizedRequest($login['accessToken'], 'GET', '/auth/ping'); + + $this->assertSame(200, $ping['status']); + $this->assertSame('ok', $ping['body']['status'] ?? null); + } + + public function testWrongPasswordIsRejected(): void + { + static::createUser('carol@example.test', 'Correct-Horse-3!'); + + $result = static::loginWithPassword('carol@example.test', 'wrong-password'); + + $this->assertSame('failed', $result['body']['status'] ?? null); + $this->assertSame(401, $result['status']); + $this->assertNull($result['accessToken']); + } + + public function testLoginFailsForUserWithoutAPasswordSet(): void + { + static::createUser('dave@example.test'); + + $result = static::loginWithPassword('dave@example.test', 'anything'); + + $this->assertSame('failed', $result['body']['status'] ?? null); + $this->assertSame(401, $result['status']); + } + + public function testLoginFailsForUnknownIdentity(): void + { + $result = static::loginWithPassword('nobody@example.test', 'anything'); + + $this->assertSame('failed', $result['body']['status'] ?? null); + $this->assertSame(401, $result['status']); + } + + public function testUnauthenticatedRequestToProtectedRouteIsRejected(): void + { + $ping = static::request('GET', '/auth/ping'); + + $this->assertSame(401, $ping['status']); + } +} diff --git a/tests/php/Integration/PasswordControllerTest.php b/tests/php/Integration/PasswordControllerTest.php new file mode 100644 index 0000000..9489b02 --- /dev/null +++ b/tests/php/Integration/PasswordControllerTest.php @@ -0,0 +1,126 @@ + 'Original-Pass-1!', + 'new_password' => 'Updated-Pass-1!', + ]); + + $this->assertSame(200, $update['status'], json_encode($update['body'])); + $this->assertTrue($update['body']['success'] ?? false); + + // Old password no longer works, new one does + $oldLogin = static::loginWithPassword('erin@example.test', 'Original-Pass-1!'); + $this->assertSame('failed', $oldLogin['body']['status'] ?? null); + + $newLogin = static::loginWithPassword('erin@example.test', 'Updated-Pass-1!'); + $this->assertSame('success', $newLogin['body']['status'] ?? null); + } + + public function testUpdatePasswordRejectsWrongCurrentPassword(): void + { + static::createUser('frank@example.test', 'Original-Pass-2!'); + $accessToken = static::loginWithPassword('frank@example.test', 'Original-Pass-2!')['accessToken']; + + $update = static::authorizedRequest($accessToken, 'POST', self::BASE . '/password/update', [ + 'current_password' => 'not-the-current-password', + 'new_password' => 'Updated-Pass-2!', + ]); + + $this->assertSame(400, $update['status']); + + // Original password still works + $login = static::loginWithPassword('frank@example.test', 'Original-Pass-2!'); + $this->assertSame('success', $login['body']['status'] ?? null); + } + + public function testUpdatePasswordRequiresAuthentication(): void + { + $update = static::request('POST', self::BASE . '/password/update', [ + 'current_password' => 'irrelevant', + 'new_password' => 'irrelevant', + ]); + + $this->assertSame(401, $update['status']); + } + + public function testAdminStatusReflectsEnrollment(): void + { + $enrolledUid = static::createUser('grace@example.test', 'Grace-Pass-1!'); + $unenrolledUid = static::createUser('heidi@example.test'); + $accessToken = static::loginWithPassword('grace@example.test', 'Grace-Pass-1!')['accessToken']; + + $enrolledStatus = static::authorizedRequest($accessToken, 'GET', self::BASE . "/admin/status/{$enrolledUid}"); + $unenrolledStatus = static::authorizedRequest($accessToken, 'GET', self::BASE . "/admin/status/{$unenrolledUid}"); + + $this->assertTrue($enrolledStatus['body']['enrolled'] ?? null); + $this->assertFalse($unenrolledStatus['body']['enrolled'] ?? null); + } + + public function testAdminResetSetsAPasswordThatCanLogIn(): void + { + $uid = static::createUser('ivan@example.test'); + // Bootstrap an authenticated actor to call the admin endpoint + static::createUser('actor1@example.test', 'Actor-Pass-1!'); + $accessToken = static::loginWithPassword('actor1@example.test', 'Actor-Pass-1!')['accessToken']; + + $reset = static::authorizedRequest($accessToken, 'POST', self::BASE . '/admin/reset', [ + 'uid' => $uid, + 'password' => 'Reset-By-Admin-1!', + ]); + + $this->assertSame(200, $reset['status'], json_encode($reset['body'])); + $this->assertTrue($reset['body']['success'] ?? false); + + $login = static::loginWithPassword('ivan@example.test', 'Reset-By-Admin-1!'); + $this->assertSame('success', $login['body']['status'] ?? null); + } + + public function testAdminResetRejectsShortPasswords(): void + { + $uid = static::createUser('judy@example.test'); + static::createUser('actor2@example.test', 'Actor-Pass-2!'); + $accessToken = static::loginWithPassword('actor2@example.test', 'Actor-Pass-2!')['accessToken']; + + $reset = static::authorizedRequest($accessToken, 'POST', self::BASE . '/admin/reset', [ + 'uid' => $uid, + 'password' => 'short', + ]); + + $this->assertSame(400, $reset['status']); + } + + public function testAdminRemoveRevokesLogin(): void + { + $uid = static::createUser('kevin@example.test', 'Kevin-Pass-1!'); + static::createUser('actor3@example.test', 'Actor-Pass-3!'); + $accessToken = static::loginWithPassword('actor3@example.test', 'Actor-Pass-3!')['accessToken']; + + $remove = static::authorizedRequest($accessToken, 'DELETE', self::BASE . "/admin/remove/{$uid}"); + $this->assertSame(200, $remove['status']); + $this->assertTrue($remove['body']['success'] ?? false); + + $status = static::authorizedRequest($accessToken, 'GET', self::BASE . "/admin/status/{$uid}"); + $this->assertFalse($status['body']['enrolled'] ?? null); + + $login = static::loginWithPassword('kevin@example.test', 'Kevin-Pass-1!'); + $this->assertSame('failed', $login['body']['status'] ?? null); + } +} diff --git a/tests/php/bootstrap.php b/tests/php/bootstrap.php new file mode 100644 index 0000000..445e927 --- /dev/null +++ b/tests/php/bootstrap.php @@ -0,0 +1,16 @@ +/modules/, so the +// server's own vendor/autoload.php (core + shared + framework deps) is a +// fixed number of levels above this file. Every other test file resolves +// the server root through this constant rather than repeating the math. +define('KTRIX_SERVER_ROOT', dirname(__DIR__, 4)); + +require KTRIX_SERVER_ROOT . '/vendor/autoload.php'; +require __DIR__ . '/Integration/IntegrationTestCase.php'; + +if (isset($_SERVER['APP_DEBUG']) && $_SERVER['APP_DEBUG']) { + umask(0000); +} diff --git a/tests/php/phpunit.integration.xml b/tests/php/phpunit.integration.xml new file mode 100644 index 0000000..a527a3c --- /dev/null +++ b/tests/php/phpunit.integration.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + Integration + + + + + +