- App\Controllers\LocaleController: GET /locale?set=xx&return=/path - Sets 1-year cookie (HttpOnly=false for SSR, SameSite=Lax, Secure on HTTPS) - 302 redirect to explicit return URL > Referer > / - Pure buildResponse() helper for unit tests (no headers/exit) - current() helper: resolves locale from $_GET/$_COOKIE/Accept-Language - safeRedirect: rejects absolute URLs, protocol-relative (//evil.com), backslash tricks (\\evil.com), javascript:/data: schemes - 28 PHPUnit tests (LocaleControllerTest), all green - Total project tests now: 92
162 lines
6.5 KiB
PHP
162 lines
6.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Controllers;
|
|
|
|
use App\Controllers\LocaleController;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
class LocaleControllerTest extends TestCase
|
|
{
|
|
// ──────────────────────────────────────────────
|
|
// buildResponse() — happy path
|
|
// ──────────────────────────────────────────────
|
|
|
|
public function testSetsCookieAndRedirectsOnValidLocale(): void
|
|
{
|
|
$resp = LocaleController::buildResponse('en', '/foo', 'https://example.com/bar', false);
|
|
|
|
$this->assertSame(302, $resp['status']);
|
|
$this->assertSame('/foo', $resp['redirect']);
|
|
$this->assertTrue($resp['set_cookie']);
|
|
$this->assertSame('en', $resp['cookie_value']);
|
|
$this->assertGreaterThan(time(), $resp['cookie_expires']);
|
|
}
|
|
|
|
public function testCookieExpiresInOneYear(): void
|
|
{
|
|
$before = time();
|
|
$resp = LocaleController::buildResponse('uk', '/', null, false);
|
|
$after = time();
|
|
|
|
$expected = 60 * 60 * 24 * 365;
|
|
$this->assertGreaterThanOrEqual($before + $expected, $resp['cookie_expires']);
|
|
$this->assertLessThanOrEqual($after + $expected, $resp['cookie_expires']);
|
|
}
|
|
|
|
public function testCookieSecureFlagMatchesHttps(): void
|
|
{
|
|
$http = LocaleController::buildResponse('en', '/', null, false);
|
|
$https = LocaleController::buildResponse('en', '/', null, true);
|
|
|
|
$this->assertFalse($http['cookie_secure']);
|
|
$this->assertTrue($https['cookie_secure']);
|
|
}
|
|
|
|
public function testSupportsAllFourLocales(): void
|
|
{
|
|
foreach (['de', 'en', 'uk', 'ru'] as $code) {
|
|
$resp = LocaleController::buildResponse($code, '/', null, false);
|
|
$this->assertTrue($resp['set_cookie'], "Locale {$code} should set cookie");
|
|
$this->assertSame($code, $resp['cookie_value']);
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// buildResponse() — invalid locale
|
|
// ──────────────────────────────────────────────
|
|
|
|
public function testInvalidLocaleDoesNotSetCookie(): void
|
|
{
|
|
$resp = LocaleController::buildResponse('fr', '/', null, false);
|
|
$this->assertFalse($resp['set_cookie']);
|
|
$this->assertSame('', $resp['cookie_value']);
|
|
}
|
|
|
|
public function testInvalidLocaleStillRedirects(): void
|
|
{
|
|
$resp = LocaleController::buildResponse('fr', '/safe-path', null, false);
|
|
$this->assertSame(302, $resp['status']);
|
|
$this->assertSame('/safe-path', $resp['redirect']);
|
|
}
|
|
|
|
public function testNullLocaleDoesNotSetCookie(): void
|
|
{
|
|
$resp = LocaleController::buildResponse(null, '/', null, false);
|
|
$this->assertFalse($resp['set_cookie']);
|
|
}
|
|
|
|
public function testEmptyStringLocaleDoesNotSetCookie(): void
|
|
{
|
|
$resp = LocaleController::buildResponse('', '/', null, false);
|
|
$this->assertFalse($resp['set_cookie']);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// safeRedirect() — return URL sanitization
|
|
// ──────────────────────────────────────────────
|
|
|
|
#[DataProvider('provideOpenRedirectAttempts')]
|
|
public function testRejectsOpenRedirects(string $bad, string $expected): void
|
|
{
|
|
$resp = LocaleController::buildResponse('en', $bad, null, false);
|
|
$this->assertSame($expected, $resp['redirect']);
|
|
}
|
|
|
|
public static function provideOpenRedirectAttempts(): array
|
|
{
|
|
return [
|
|
'absolute https' => ['https://evil.com/phish', '/'],
|
|
'absolute http' => ['http://evil.com/phish', '/'],
|
|
'protocol-relative' => ['//evil.com/phish', '/'],
|
|
'scheme-relative upper' => ['//EVIL.COM/phish', '/'],
|
|
'javascript scheme' => ['javascript:alert(1)', '/'],
|
|
'data scheme' => ['data:text/html,<script>', '/'],
|
|
'no leading slash' => ['foo/bar', '/'],
|
|
'backslash trick' => ['/\\evil.com', '/'],
|
|
'double backslash' => ['\\\\evil.com', '/'],
|
|
];
|
|
}
|
|
|
|
#[DataProvider('provideValidRelativePaths')]
|
|
public function testAcceptsValidRelativePaths(string $path): void
|
|
{
|
|
$resp = LocaleController::buildResponse('en', $path, null, false);
|
|
$this->assertSame($path, $resp['redirect']);
|
|
}
|
|
|
|
public static function provideValidRelativePaths(): array
|
|
{
|
|
return [
|
|
'root' => ['/'],
|
|
'home' => ['/'],
|
|
'impressum' => ['/impressum'],
|
|
'datenschutz' => ['/datenschutz'],
|
|
'with query' => ['/foo?bar=1'],
|
|
'with hash' => ['/foo#section'],
|
|
'with deep' => ['/some/deep/path'],
|
|
];
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Referer fallback chain
|
|
// ──────────────────────────────────────────────
|
|
|
|
public function testFallsBackToRefererWhenExplicitReturnIsNull(): void
|
|
{
|
|
$resp = LocaleController::buildResponse('en', null, 'https://example.com/landing', false);
|
|
// Referer is absolute, gets stripped to '/'
|
|
$this->assertSame('/', $resp['redirect']);
|
|
}
|
|
|
|
public function testFallsBackToRootWhenBothExplicitAndRefererMissing(): void
|
|
{
|
|
$resp = LocaleController::buildResponse('en', null, null, false);
|
|
$this->assertSame('/', $resp['redirect']);
|
|
}
|
|
|
|
public function testFallsBackToRootWhenRefererIsEmpty(): void
|
|
{
|
|
$resp = LocaleController::buildResponse('en', null, '', false);
|
|
$this->assertSame('/', $resp['redirect']);
|
|
}
|
|
|
|
public function testExplicitReturnBeatsReferer(): void
|
|
{
|
|
$resp = LocaleController::buildResponse('en', '/chosen', 'https://other.com/other', false);
|
|
$this->assertSame('/chosen', $resp['redirect']);
|
|
}
|
|
}
|