M4: PWA + Mobile-UX – Manifest/Service-Worker + Icons, Auf/Ab-Reorder, FAB, Touch-Targets + Safe-Area, Nav-Entdupidung, Heute-Highlight, Aktions-Trennung (Abhaken vs. Bearbeiten/Löschen), OIDC-Fixes + PWA-Tests

This commit is contained in:
2026-09-20 13:04:52 +02:00
parent 0db09bf098
commit f4c20d0504
23 changed files with 938 additions and 161 deletions
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\UI\Essen;
use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\BrowserKit\Cookie;
final class PwaSmokeTest extends WebTestCase
{
private static function authedClient(): KernelBrowser
{
$client = self::createClient();
$client->followRedirects(false);
$em = self::getContainer()->get('doctrine')->getManager();
$schemaTool = new SchemaTool($em);
$schemaTool->dropSchema($em->getMetadataFactory()->getAllMetadata());
$schemaTool->createSchema($em->getMetadataFactory()->getAllMetadata());
$session = self::getContainer()->get('session.factory')->createSession();
$session->start();
foreach (['auth_sub' => 'test-sub', 'auth_name' => 'Jens', 'auth_email' => 'jens@ljh31.de'] as $k => $v) {
$session->set($k, $v);
}
$session->save();
$client->getCookieJar()->set(new Cookie($session->getName(), $session->getId()));
return $client;
}
public function testIndexHasPwaAndMobileMarkers(): void
{
$client = self::authedClient();
$client->request('GET', '/mahlzeiten');
self::assertResponseIsSuccessful();
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('manifest.webmanifest', $html);
self::assertStringContainsString('class="fab"', $html);
self::assertStringContainsString("register('/sw.js')", $html);
self::assertStringContainsString('href="/mahlzeiten/tage"', $html);
}
public function testTageHighlightsHeutigerTag(): void
{
$client = self::authedClient();
$client->request('POST', '/mahlzeiten', [
'name' => 'Heutiges Gericht',
'notiz' => '',
'wochentage' => [self::heuteWert()],
]);
$client->request('GET', '/mahlzeiten/tage');
self::assertResponseIsSuccessful();
$h2 = $client->getCrawler()->filterXPath('//h2[@data-heute="1"]');
self::assertCount(1, $h2, 'Genau ein <h2> trägt das data-heute-Attribut.');
self::assertSame(self::heuteName(), trim($h2->text()));
}
private static function heuteName(): string
{
$iso = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('N');
return ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'][$iso - 1];
}
private static function heuteWert(): string
{
$iso = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('N');
return ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'][$iso - 1];
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\UI\Http\Auth;
use App\UI\Http\Auth\OidcClient;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
/**
* Regressions-Test für die RS256-IdToken-Verifizierung:
* Signiert ein JWT gegen einen lokal generierten RSA-Schlüssel und prüft
* OidcClient::verifyIdToken() inkl. JWKS→PEM-Konvertierung.
*/
final class OidcClientTest extends TestCase
{
private const ISSUER = 'https://issuer.test/realms/family';
private const CLIENT_ID = 'essen';
private const KID = 'test-kid-signing';
/** @var resource */
private $key;
/** @var array{n: string, e: string} */
private array $jwk;
protected function setUp(): void
{
$this->key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
if ($this->key === false) {
self::fail('konnte RSA-Schlüssel generieren.');
}
$details = openssl_pkey_get_details($this->key);
$n = $details['n'] ?? $details['rsa']['n'];
$e = $details['e'] ?? $details['rsa']['e'];
$this->jwk = [
'n' => self::b64url($n),
'e' => self::b64url($e),
];
}
public function testVerifyIdTokenAkzeptiertGueltigSigniertesToken(): void
{
$jwt = $this->signJwt(['sub' => 'jens@ljh31.de', 'email' => 'jens@ljh31.de']);
$claims = $this->oidcClient()->verifyIdToken($jwt);
self::assertSame('jens@ljh31.de', $claims['sub']);
self::assertSame('jens@ljh31.de', $claims['email']);
self::assertSame(self::ISSUER, $claims['iss']);
}
public function testVerifyIdTokenAkzeptiertAudienceAlsArray(): void
{
$jwt = $this->signJwt(['sub' => 'x'], ['aud' => ['essen', 'anderer-client']]);
$claims = $this->oidcClient()->verifyIdToken($jwt);
self::assertSame('x', $claims['sub']);
}
public function testVerifyIdTokenVerwirftManipuliertesPayload(): void
{
$jwt = $this->signJwt(['sub' => 'jens@ljh31.de']);
$tampered = $this->tamper($jwt, 'jens@ljh31.de', 'admin@ljh31.de');
$this->expectException(\RuntimeException::class);
$this->oidcClient()->verifyIdToken($tampered);
}
public function testVerifyIdTokenVerwirftFalscheIssuer(): void
{
$jwt = $this->signJwt(['sub' => 'x'], ['iss' => 'https://anderer.issuer/realms/family']);
$this->expectException(\RuntimeException::class);
$this->oidcClient()->verifyIdToken($jwt);
}
public function testVerifyIdTokenVerwirftFalscheAudience(): void
{
$jwt = $this->signJwt(['sub' => 'x'], ['aud' => 'falscher-client']);
$this->expectException(\RuntimeException::class);
$this->oidcClient()->verifyIdToken($jwt);
}
public function testVerifyIdTokenVerwirftAbgelaufenesToken(): void
{
$jwt = $this->signJwt(['sub' => 'x'], ['exp' => time() - 3600]);
$this->expectException(\RuntimeException::class);
$this->oidcClient()->verifyIdToken($jwt);
}
public function testVerifyIdTokenWähltRichtigenKeyBeiMehrfachJwks(): void
{
// Zusätzliches RSA-OAEP-Enc-Key (wie in der echten Keycloak-Realm):
// muss ignoriert werden, obwohl es denselben Modulus teilt.
$jwt = $this->signJwt(['sub' => 'x']);
$claims = $this->oidcClient(extraKey: [
'kty' => 'RSA',
'alg' => 'RSA-OAEP',
'use' => 'enc',
'kid' => 'test-kid-enc',
'n' => $this->jwk['n'],
'e' => $this->jwk['e'],
])->verifyIdToken($jwt);
self::assertSame('x', $claims['sub']);
}
private function signJwt(array $claims, array $overrides = []): string
{
$header = ['alg' => 'RS256', 'typ' => 'JWT', 'kid' => self::KID];
$payload = array_merge([
'iss' => self::ISSUER,
'aud' => self::CLIENT_ID,
'exp' => time() + 300,
'iat' => time(),
], $overrides, $claims);
$h64 = self::b64url((string) json_encode($header));
$p64 = self::b64url((string) json_encode($payload));
if (openssl_sign("$h64.$p64", $sig, $this->key, OPENSSL_ALGO_SHA256) !== true) {
self::fail('konnte Test-JWT signieren.');
}
return "$h64.$p64." . self::b64url($sig);
}
private function tamper(string $jwt, string $from, string $to): string
{
$parts = explode('.', $jwt);
$payload = json_decode((string) base64_decode(strtr($parts[1], '-_', '+/')), true);
$payload['sub'] = $to;
$parts[1] = self::b64url((string) json_encode($payload));
return implode('.', $parts);
}
private function oidcClient(?array $extraKey = null): OidcClient
{
$keys = [[
'kty' => 'RSA',
'alg' => 'RS256',
'use' => 'sig',
'kid' => self::KID,
'n' => $this->jwk['n'],
'e' => $this->jwk['e'],
]];
if ($extraKey !== null) {
array_unshift($keys, $extraKey);
}
$httpClient = new MockHttpClient(new MockResponse((string) json_encode(['keys' => $keys])));
return new OidcClient(
httpClient: $httpClient,
issuer: self::ISSUER,
clientId: self::CLIENT_ID,
clientSecret: null,
redirectUri: 'https://app.test/auth/callback',
);
}
private static function b64url(string $binary): string
{
return rtrim(strtr(base64_encode($binary), '+/', '-_'), '=');
}
}