From eafe12b588f63b5013e532ffbe0e02627fc2a092 Mon Sep 17 00:00:00 2001 From: magdev Date: Sat, 2 May 2026 01:05:19 +0200 Subject: [PATCH] Phase 1 sub-commit 2: Symfony bundle internals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle code for php-qml/bridge: BridgeBundle (AbstractBundle, autoloads config/services.yaml), Publisher (thin wrapper over Mercure HubInterface that enforces envelope-as-JSON), SessionAuthenticator (bearer-token custom Symfony authenticator with problem+json failures), and HealthController (GET /healthz readiness probe). Composer constraints bumped to Symfony ^8.0 across the board (per user request); mercure component to ^0.7. PHPUnit 11 suite covers Publisher publish + private flag and SessionAuthenticator support/auth/failure paths — 8 tests, 22 assertions, all green. PLAN.md §13 updated to record the Symfony 8 minimum. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 2 + PLAN.md | 2 +- framework/php/composer.json | 11 ++- framework/php/config/services.yaml | 13 +++ framework/php/phpunit.xml.dist | 21 +++++ framework/php/src/BridgeBundle.php | 24 ++++++ .../php/src/Controller/HealthController.php | 21 +++++ framework/php/src/Publisher.php | 35 ++++++++ framework/php/src/SessionAuthenticator.php | 72 ++++++++++++++++ framework/php/tests/PublisherTest.php | 72 ++++++++++++++++ .../php/tests/SessionAuthenticatorTest.php | 86 +++++++++++++++++++ 11 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 framework/php/config/services.yaml create mode 100644 framework/php/phpunit.xml.dist create mode 100644 framework/php/src/BridgeBundle.php create mode 100644 framework/php/src/Controller/HealthController.php create mode 100644 framework/php/src/Publisher.php create mode 100644 framework/php/src/SessionAuthenticator.php create mode 100644 framework/php/tests/PublisherTest.php create mode 100644 framework/php/tests/SessionAuthenticatorTest.php diff --git a/.gitignore b/.gitignore index b3f0f76..376f86d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ build/ # Composer vendor/ composer.phar +# Library packages don't ship composer.lock; applications do. +framework/php/composer.lock # PHPUnit .phpunit.cache/ diff --git a/PLAN.md b/PLAN.md index b6264a3..b152237 100644 --- a/PLAN.md +++ b/PLAN.md @@ -584,7 +584,7 @@ Phase 1 turns the spike into the smallest dev-mode-only framework that can repla | PHP namespace | `PhpQml\Bridge\` | | Qt module URI | `PhpQml.Bridge` | | C++ namespace | `PhpQml::Bridge` | -| Symfony minimum | `^7.1` | +| Symfony minimum | `^8.0` | | PHP minimum | `^8.3` | | Qt minimum | `6.5 LTS` (build), `6.11` is what's on the dev box | diff --git a/framework/php/composer.json b/framework/php/composer.json index 98c5fdf..20fb4b7 100644 --- a/framework/php/composer.json +++ b/framework/php/composer.json @@ -5,14 +5,21 @@ "license": "proprietary", "require": { "php": "^8.3", - "symfony/framework-bundle": "^7.1" + "symfony/framework-bundle": "^8.0", + "symfony/mercure": "^0.7", + "symfony/security-bundle": "^8.0", + "symfony/routing": "^8.0", + "symfony/http-foundation": "^8.0", + "symfony/console": "^8.0", + "symfony/dependency-injection": "^8.0", + "symfony/config": "^8.0" }, "require-dev": { "phpunit/phpunit": "^11", "phpstan/phpstan": "^2", "phpstan/phpstan-symfony": "^2", "friendsofphp/php-cs-fixer": "^3", - "symfony/phpunit-bridge": "^7.1" + "symfony/phpunit-bridge": "^8.0" }, "autoload": { "psr-4": { diff --git a/framework/php/config/services.yaml b/framework/php/config/services.yaml new file mode 100644 index 0000000..db06849 --- /dev/null +++ b/framework/php/config/services.yaml @@ -0,0 +1,13 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + PhpQml\Bridge\: + resource: '../src/' + exclude: + - '../src/BridgeBundle.php' + + PhpQml\Bridge\SessionAuthenticator: + arguments: + $expectedToken: '%env(default::BRIDGE_TOKEN)%' diff --git a/framework/php/phpunit.xml.dist b/framework/php/phpunit.xml.dist new file mode 100644 index 0000000..ca057df --- /dev/null +++ b/framework/php/phpunit.xml.dist @@ -0,0 +1,21 @@ + + + + + tests/ + + + + + + src + + + diff --git a/framework/php/src/BridgeBundle.php b/framework/php/src/BridgeBundle.php new file mode 100644 index 0000000..dd28b3d --- /dev/null +++ b/framework/php/src/BridgeBundle.php @@ -0,0 +1,24 @@ +import(__DIR__ . '/../config/services.yaml'); + } + + public function configure(DefinitionConfigurator $definition): void + { + // Bundle config tree gains nodes when bridge:doctor and the + // skeleton's wiring need settable knobs (Phase 1 sub-commits 3 & 6). + } +} diff --git a/framework/php/src/Controller/HealthController.php b/framework/php/src/Controller/HealthController.php new file mode 100644 index 0000000..b989cbf --- /dev/null +++ b/framework/php/src/Controller/HealthController.php @@ -0,0 +1,21 @@ + 'ok']); + } +} diff --git a/framework/php/src/Publisher.php b/framework/php/src/Publisher.php new file mode 100644 index 0000000..1fedcb6 --- /dev/null +++ b/framework/php/src/Publisher.php @@ -0,0 +1,35 @@ + $envelope + */ + public function publish(string $topic, array $envelope, bool $private = false): string + { + return $this->hub->publish(new Update( + $topic, + json_encode($envelope, JSON_THROW_ON_ERROR), + $private, + )); + } +} diff --git a/framework/php/src/SessionAuthenticator.php b/framework/php/src/SessionAuthenticator.php new file mode 100644 index 0000000..f635145 --- /dev/null +++ b/framework/php/src/SessionAuthenticator.php @@ -0,0 +1,72 @@ +headers->has('Authorization'); + } + + public function authenticate(Request $request): Passport + { + $header = (string) $request->headers->get('Authorization', ''); + if (!str_starts_with($header, 'Bearer ')) { + throw new AuthenticationException('Bearer token missing.'); + } + + $token = substr($header, 7); + if ($this->expectedToken === '' || !hash_equals($this->expectedToken, $token)) { + throw new AuthenticationException('Bearer token invalid.'); + } + + // Single-session model — there is one bridge "user", not per-end-user auth. + return new SelfValidatingPassport(new UserBadge('bridge')); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return new JsonResponse( + [ + 'type' => 'about:blank', + 'title' => 'Unauthorized', + 'status' => Response::HTTP_UNAUTHORIZED, + 'detail' => $exception->getMessage(), + ], + Response::HTTP_UNAUTHORIZED, + ['Content-Type' => 'application/problem+json'], + ); + } +} diff --git a/framework/php/tests/PublisherTest.php b/framework/php/tests/PublisherTest.php new file mode 100644 index 0000000..ac8c6a6 --- /dev/null +++ b/framework/php/tests/PublisherTest.php @@ -0,0 +1,72 @@ +getUrl(); } + + public function getProvider(): \Symfony\Component\Mercure\Jwt\TokenProviderInterface + { + throw new \LogicException('not used in test'); + } + + public function getFactory(): ?\Symfony\Component\Mercure\Jwt\TokenFactoryInterface + { + return null; + } + + public function publish(Update $update): string + { + $this->captured = $update; + return 'urn:uuid:test'; + } + }; + + $publisher = new Publisher($hub); + $id = $publisher->publish('app://model/todo', ['op' => 'upsert', 'id' => '1', 'data' => ['done' => true], 'version' => 7]); + + self::assertSame('urn:uuid:test', $id); + self::assertInstanceOf(Update::class, $captured); + self::assertSame(['app://model/todo'], $captured->getTopics()); + self::assertJsonStringEqualsJsonString( + '{"op":"upsert","id":"1","data":{"done":true},"version":7}', + $captured->getData(), + ); + self::assertFalse($captured->isPrivate()); + } + + public function testPrivateFlagIsForwarded(): void + { + $captured = null; + $hub = new class($captured) implements HubInterface { + public function __construct(private mixed &$captured) {} + public function getUrl(): string { return ''; } + public function getPublicUrl(): string { return ''; } + public function getProvider(): \Symfony\Component\Mercure\Jwt\TokenProviderInterface { throw new \LogicException(); } + public function getFactory(): ?\Symfony\Component\Mercure\Jwt\TokenFactoryInterface { return null; } + public function publish(Update $update): string { $this->captured = $update; return ''; } + }; + + (new Publisher($hub))->publish('app://event/internal', ['data' => 'x'], private: true); + + self::assertTrue($captured->isPrivate()); + } +} diff --git a/framework/php/tests/SessionAuthenticatorTest.php b/framework/php/tests/SessionAuthenticatorTest.php new file mode 100644 index 0000000..eac0b86 --- /dev/null +++ b/framework/php/tests/SessionAuthenticatorTest.php @@ -0,0 +1,86 @@ +supports(new Request())); + + $request = new Request(); + $request->headers->set('Authorization', 'Bearer s3cret'); + self::assertTrue($auth->supports($request)); + } + + public function testAuthenticateAcceptsMatchingBearerToken(): void + { + $auth = new SessionAuthenticator('s3cret'); + $request = new Request(); + $request->headers->set('Authorization', 'Bearer s3cret'); + + $passport = $auth->authenticate($request); + + self::assertInstanceOf(SelfValidatingPassport::class, $passport); + self::assertSame('bridge', $passport->getBadge(\Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge::class)->getUserIdentifier()); + } + + public function testAuthenticateRejectsMissingBearerScheme(): void + { + $auth = new SessionAuthenticator('s3cret'); + $request = new Request(); + $request->headers->set('Authorization', 'Basic deadbeef'); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Bearer token missing.'); + $auth->authenticate($request); + } + + public function testAuthenticateRejectsWrongToken(): void + { + $auth = new SessionAuthenticator('s3cret'); + $request = new Request(); + $request->headers->set('Authorization', 'Bearer wrong'); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Bearer token invalid.'); + $auth->authenticate($request); + } + + public function testAuthenticateRejectsEmptyExpectedToken(): void + { + // Avoids passing a misconfigured (empty) deployment. + $auth = new SessionAuthenticator(''); + $request = new Request(); + $request->headers->set('Authorization', 'Bearer '); + + $this->expectException(AuthenticationException::class); + $auth->authenticate($request); + } + + public function testAuthenticationFailureProducesProblemJson(): void + { + $auth = new SessionAuthenticator('s3cret'); + $response = $auth->onAuthenticationFailure(new Request(), new AuthenticationException('Bearer token invalid.')); + + self::assertNotNull($response); + self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); + self::assertSame('application/problem+json', $response->headers->get('Content-Type')); + $body = json_decode((string) $response->getContent(), true); + self::assertSame(401, $body['status']); + self::assertSame('Unauthorized', $body['title']); + } +}