M0: Skelett + Auth-Grundgerüst (Keycloak-OIDC + API-Token)

- Symfony 7.4 LTS, PHP 8.4, FrankenPHP, SQLite (3-Layer src/UI|Logic|Data)
- Auth: manueller OidcClient (Authorization-Code) im UI-Layer, kein
  Security-Bundle/league; Session-Gate via SessionAuthSubscriber
- API-Guard via statischem X-Api-Token (ApiTokenSubscriber); ExceptionSubscriber
- UserEntity (Auto-Provision) + Init-Migration
- Docker (php:8.4-frankenphp) + compose (essen.ljh31.de, traefik-net external)
- SmokeTests (7) grün: Redirect-Fälle, API-Token, Auth-Render, Logout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 22:48:24 +02:00
co-authored by Claude Opus 4.8
parent 3c0bee5826
commit cb3cbfb394
56 changed files with 9596 additions and 19 deletions
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\BrowserKit\Cookie;
final class SmokeTest extends WebTestCase
{
private const AUTH_KEYS = [
'auth_sub' => 'test-sub',
'auth_name' => 'Jens',
'auth_email' => 'jens@ljh31.de',
];
private static function client(): KernelBrowser
{
$client = self::createClient();
$client->followRedirects(false);
return $client;
}
/** Client, dessen Session bereits eine eingeloggte Identität enthält. */
private static function authedClient(): KernelBrowser
{
$client = self::client();
$session = self::getContainer()->get('session.factory')->createSession();
$session->start();
foreach (self::AUTH_KEYS as $k => $v) {
$session->set($k, $v);
}
$session->save();
$client->getCookieJar()->set(new Cookie($session->getName(), $session->getId()));
return $client;
}
public function testRootRedirectsToLoginWithoutSession(): void
{
self::client()->request('GET', '/');
self::assertResponseStatusCodeSame(302);
self::assertResponseRedirects('/login?redirect=/');
}
public function testLoginRedirectsToKeycloakAuthorizeEndpoint(): void
{
$client = self::client();
$client->request('GET', '/login');
self::assertResponseStatusCodeSame(302);
$location = (string) $client->getResponse()->headers->get('Location');
self::assertStringContainsString('/protocol/openid-connect/auth', $location);
self::assertStringContainsString('client_id=essens-test', $location);
}
public function testCallbackWithWrongStateRedirectsBackToLogin(): void
{
self::client()->request('GET', '/auth/callback?code=xyz&state=falsch-state');
self::assertResponseStatusCodeSame(302);
self::assertResponseRedirects('/login');
}
public function testApiWithoutTokenReturns401Json(): void
{
$client = self::client();
$client->request('GET', '/api/today');
self::assertResponseStatusCodeSame(401);
$body = json_decode((string) $client->getResponse()->getContent(), true);
self::assertIsArray($body);
self::assertArrayHasKey('error', $body);
}
public function testApiWithValidTokenGets404Not401(): void
{
// /api/today existiert in M0 noch nicht (M4) → Guard lässt durch, Router gibt 404.
$client = self::client();
$client->request('GET', '/api/today', server: ['HTTP_X_API_TOKEN' => 'test-api-token']);
self::assertSame(404, $client->getResponse()->getStatusCode());
}
public function testRootRendersForAuthenticatedSession(): void
{
self::authedClient()->request('GET', '/');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('h1', 'Jens');
}
public function testLogoutRedirectsToLogin(): void
{
self::authedClient()->request('GET', '/logout');
self::assertResponseStatusCodeSame(302);
self::assertResponseRedirects('/login');
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php';
(new Dotenv())->bootEnv(dirname(__DIR__) . '/.env');