Add PHPUnit test suite and supporting infrastructure
- Add phpunit/phpunit ^13.2, symfony/browser-kit and symfony/css-selector to require-dev, plus the autoload-dev mapping for App\Tests- Add phpunit.dist.xml (strict deprecation/notice/warning failures, APP_ENV=test forced) and .env.test / bin/phpunit / tests/bootstrap.php from the PHPUnit recipe - Add tests/Support/TotpTestHelper providing a deterministic TOTP fixture, frozen clock and ConfigBag/cache-pool helpers - Add 121 unit tests covering Clock, ConfigBag, Data/Payload, Enum/Scope, MonitorCacheKeys, PersistCache, Utilities, all five Traits and the three Service managers (BackupCode, Domain, Login) - Fix LoginManagerTest nonce lookups to use makeCacheKey() so the cache key matches the one the manager actually reads/writes - Gitignore bin/.phpunit.result.cache
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
APP_ENV=test
|
||||
APP_DEBUG=0
|
||||
APP_SECRET=test_secret_key_change_me
|
||||
TOTP_URI=''
|
||||
COOKIE_TTL=2592000
|
||||
SUBDOMAIN_REDIRECT=0
|
||||
AUTH_SUBDOMAIN=''
|
||||
IP_TTL=0
|
||||
TEAPOT=1
|
||||
BURST_COUNT=10
|
||||
BURST_TIME=30
|
||||
UPPER_COUNT=100
|
||||
UPPER_TIME=3600
|
||||
TITLE='Pre-Authentication System'
|
||||
BG_COLOR='#029386'
|
||||
FG_COLOR='#ffffff'
|
||||
ERROR_COLOR='#ffb16d'
|
||||
ID_NAME='Session ID'
|
||||
TOKEN_NAME='Authentication Token'
|
||||
SUBMIT_NAME='Submit'
|
||||
ERROR_MESSAGE='Unsuccessful login attempt'
|
||||
TEAPOT_TITLE="I'm a teapot"
|
||||
TEAPOT_MESSAGE='I refuse to brew coffee'
|
||||
TOO_MANY_TITLE='Too many requests'
|
||||
TOO_MANY_MESSAGE='Try again later'
|
||||
SHELL_VERBOSITY=0
|
||||
@@ -6,3 +6,9 @@
|
||||
/vendor/
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
|
||||
###> phpunit/phpunit ###
|
||||
/phpunit.xml
|
||||
/.phpunit.cache/
|
||||
/bin/.phpunit.result.cache
|
||||
###< phpunit/phpunit ###
|
||||
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
|
||||
@@ -35,6 +35,11 @@
|
||||
"App\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"App\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"replace": {
|
||||
"symfony/polyfill-ctype": "*",
|
||||
"symfony/polyfill-iconv": "*",
|
||||
@@ -68,5 +73,10 @@
|
||||
"allow-contrib": false,
|
||||
"require": "7.4.*"
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^13.2",
|
||||
"symfony/browser-kit": "7.4.*",
|
||||
"symfony/css-selector": "7.4.*"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2204
-3
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
colors="true"
|
||||
failOnDeprecation="true"
|
||||
failOnNotice="true"
|
||||
failOnWarning="true"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
>
|
||||
<php>
|
||||
<ini name="display_errors" value="1" />
|
||||
<ini name="error_reporting" value="-1" />
|
||||
<server name="APP_ENV" value="test" force="true" />
|
||||
<server name="SHELL_VERBOSITY" value="-1" />
|
||||
</php>
|
||||
|
||||
<testsuites>
|
||||
<testsuite name="Project Test Suite">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source ignoreSuppressionOfDeprecations="true"
|
||||
ignoreIndirectDeprecations="true"
|
||||
restrictNotices="true"
|
||||
restrictWarnings="true"
|
||||
>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
|
||||
<deprecationTrigger>
|
||||
<function>trigger_deprecation</function>
|
||||
</deprecationTrigger>
|
||||
</source>
|
||||
|
||||
<extensions>
|
||||
</extensions>
|
||||
</phpunit>
|
||||
@@ -1,4 +1,19 @@
|
||||
{
|
||||
"phpunit/phpunit": {
|
||||
"version": "13.2",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "11.1",
|
||||
"ref": "ca0bc067abfb40a8de1b2561b96cbfc2b833c314"
|
||||
},
|
||||
"files": [
|
||||
".env.test",
|
||||
"phpunit.dist.xml",
|
||||
"tests/bootstrap.php",
|
||||
"bin/phpunit"
|
||||
]
|
||||
},
|
||||
"symfony/console": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Support;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Utilities;
|
||||
use DateTimeImmutable;
|
||||
use OTPHP\TOTP;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Clock\ClockInterface as PsrClockInterface;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
|
||||
/**
|
||||
* Provides a deterministic TOTP fixture plus a frozen clock and ready-made
|
||||
* ConfigBag / cache-pool helpers for tests that exercise TOTP-dependent code.
|
||||
*/
|
||||
trait TotpTestHelper {
|
||||
/** well-known Base32 test secret (JBSWY3DPEHPK3PXP) */
|
||||
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
|
||||
|
||||
/** Frozen timestamp used for deterministic TOTP codes. */
|
||||
protected const string FROZEN_TIME = '2025-06-15 12:00:00';
|
||||
|
||||
/** Frozen clock that always returns the same instant. */
|
||||
private function frozenClock(): PsrClockInterface {
|
||||
$time = self::FROZEN_TIME;
|
||||
return new class($time) implements PsrClockInterface {
|
||||
public function __construct(private string $time) {}
|
||||
public function now(): DateTimeImmutable {
|
||||
return new DateTimeImmutable($this->time);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Provisioning URI built from the well-known secret + frozen clock. */
|
||||
private function totpUri(): string {
|
||||
$totp = TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock());
|
||||
$totp->setLabel('Test-TOTP');
|
||||
return $totp->getProvisioningUri();
|
||||
}
|
||||
|
||||
/** The TOTP code that is valid at the frozen timestamp. */
|
||||
private function validTotpCode(): string {
|
||||
return TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock())->now();
|
||||
}
|
||||
|
||||
/** A fresh in-memory cache pool suitable for wrapping in MonitorCacheKeys. */
|
||||
private function emptyPool(): CacheItemPoolInterface {
|
||||
return new ArrayAdapter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ConfigBag wired with the deterministic TOTP and frozen clock.
|
||||
* Extra params override the sensible defaults.
|
||||
*/
|
||||
private function makeConfig(
|
||||
?int $cookieTtl = 3600,
|
||||
?int $ipTtl = 0,
|
||||
bool $teapot = true,
|
||||
string $errorMessage = 'Error',
|
||||
string $teapotTitle = 'Teapot',
|
||||
string $tooManyTitle = 'Too Many',
|
||||
): ConfigBag {
|
||||
$clock = $this->frozenClock();
|
||||
$utilities = $this->createUtilities($clock);
|
||||
return new ConfigBag(
|
||||
$utilities, $clock,
|
||||
$cookieTtl, $this->totpUri(), $ipTtl, $teapot,
|
||||
$errorMessage, $teapotTitle, $tooManyTitle,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal Utilities stub that never triggers TOTP generation when
|
||||
* a non-empty totpUri is supplied to ConfigBag.
|
||||
*/
|
||||
private function createUtilities(?PsrClockInterface $clock = null): Utilities {
|
||||
$clock ??= $this->frozenClock();
|
||||
$cache = $this->createStub(CacheItemPoolInterface::class);
|
||||
$cache->method('hasItem')->willReturn(false);
|
||||
$item = $this->createStub(CacheItemInterface::class);
|
||||
$item->method('isHit')->willReturn(false);
|
||||
$cache->method('getItem')->willReturn($item);
|
||||
return new Utilities($clock, $cache);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit;
|
||||
|
||||
use App\Clock;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ClockTest extends TestCase {
|
||||
public function testNowReturnsDateTimeImmutable(): void {
|
||||
$clock = new Clock();
|
||||
$before = new \DateTimeImmutable();
|
||||
$now = $clock->now();
|
||||
$after = new \DateTimeImmutable();
|
||||
|
||||
self::assertInstanceOf(\DateTimeImmutable::class, $now);
|
||||
self::assertGreaterThanOrEqual($before->getTimestamp(), $now->getTimestamp());
|
||||
self::assertLessThanOrEqual($after->getTimestamp(), $now->getTimestamp());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Utilities;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Clock\ClockInterface;
|
||||
|
||||
final class ConfigBagTest extends TestCase {
|
||||
private function createUtilities(?string $totp = null): Utilities {
|
||||
$clock = $this->createStub(ClockInterface::class);
|
||||
$cache = $this->createStub(CacheItemPoolInterface::class);
|
||||
|
||||
if ($totp !== null) {
|
||||
$item = $this->createStub(CacheItemInterface::class);
|
||||
$item->method('isHit')->willReturn(true);
|
||||
$item->method('get')->willReturn($totp);
|
||||
$cache->method('hasItem')->willReturn(true);
|
||||
$cache->method('getItem')->willReturn($item);
|
||||
} else {
|
||||
$cache->method('hasItem')->willReturn(false);
|
||||
}
|
||||
|
||||
return new Utilities($clock, $cache);
|
||||
}
|
||||
|
||||
public function testGettersWithExplicitValues(): void {
|
||||
$clock = $this->createStub(ClockInterface::class);
|
||||
$utilities = $this->createUtilities();
|
||||
|
||||
$config = new ConfigBag(
|
||||
$utilities, $clock,
|
||||
3600, 'otpauth://totp/test', 1800, true,
|
||||
'Error!', 'Teapot!', 'Too Many!'
|
||||
);
|
||||
|
||||
self::assertSame($clock, $config->clock());
|
||||
self::assertSame(3600, $config->cookieTtl());
|
||||
self::assertSame('otpauth://totp/test', $config->totpUri());
|
||||
self::assertSame(1800, $config->ipTtl());
|
||||
self::assertTrue($config->teapot());
|
||||
self::assertSame('Error!', $config->errorMessage());
|
||||
self::assertSame('Teapot!', $config->teapotTitle());
|
||||
self::assertSame('Too Many!', $config->tooManyTitle());
|
||||
}
|
||||
|
||||
public function testTotpUriFallsBackToUtilitiesWhenEmpty(): void {
|
||||
$clock = $this->createStub(ClockInterface::class);
|
||||
$utilities = $this->createUtilities('fallback-totp');
|
||||
|
||||
$config = new ConfigBag(
|
||||
$utilities, $clock,
|
||||
3600, '', 1800, false,
|
||||
'Error', 'Teapot', 'Too Many'
|
||||
);
|
||||
|
||||
self::assertSame('fallback-totp', $config->totpUri());
|
||||
}
|
||||
|
||||
public function testIpTtlFallsBackToNullWhenZero(): void {
|
||||
$clock = $this->createStub(ClockInterface::class);
|
||||
$utilities = $this->createUtilities();
|
||||
|
||||
$config = new ConfigBag(
|
||||
$utilities, $clock,
|
||||
3600, 'otpauth://totp/test', 0, false,
|
||||
'Error', 'Teapot', 'Too Many'
|
||||
);
|
||||
|
||||
self::assertNull($config->ipTtl());
|
||||
}
|
||||
|
||||
public function testIpTtlFallsBackToNullWhenNull(): void {
|
||||
$clock = $this->createStub(ClockInterface::class);
|
||||
$utilities = $this->createUtilities();
|
||||
|
||||
$config = new ConfigBag(
|
||||
$utilities, $clock,
|
||||
3600, 'otpauth://totp/test', null, false,
|
||||
'Error', 'Teapot', 'Too Many'
|
||||
);
|
||||
|
||||
self::assertNull($config->ipTtl());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Data;
|
||||
|
||||
use App\Data\Payload;
|
||||
use App\Enum\Scope;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\InputBag;
|
||||
|
||||
final class PayloadTest extends TestCase {
|
||||
private static function b64u(string $data): string {
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
public function testDecodeValidBase64Url(): void {
|
||||
$data = json_encode([
|
||||
'id' => 'testuser', 'token' => '123456', 'nonce' => 'abc123',
|
||||
'json' => true, 'scope' => 'cookie',
|
||||
]);
|
||||
$payload = Payload::decode(self::b64u($data));
|
||||
|
||||
self::assertInstanceOf(Payload::class, $payload);
|
||||
self::assertSame('testuser', $payload->id);
|
||||
self::assertSame('123456', $payload->token);
|
||||
self::assertSame('abc123', $payload->nonce);
|
||||
self::assertTrue($payload->json);
|
||||
self::assertSame(Scope::Cookie, $payload->scope);
|
||||
}
|
||||
|
||||
public function testDecodeInvalidBase64UrlReturnsNull(): void {
|
||||
self::assertNull(Payload::decode('!!!not-valid-base64!!!'));
|
||||
}
|
||||
|
||||
public function testDecodeNonObjectJsonReturnsNull(): void {
|
||||
self::assertNull(Payload::decode(self::b64u('"just a string"')));
|
||||
}
|
||||
|
||||
public function testDecodeEmptyStringReturnsNull(): void {
|
||||
self::assertNull(Payload::decode(''));
|
||||
}
|
||||
|
||||
public function testLoadWithValidInputBag(): void {
|
||||
$input = new InputBag([
|
||||
'username' => 'alice', 'nonce' => 'nonce123', 'totp' => '654321',
|
||||
]);
|
||||
$payload = Payload::load($input);
|
||||
|
||||
self::assertInstanceOf(Payload::class, $payload);
|
||||
self::assertSame('alice', $payload->id);
|
||||
self::assertSame('nonce123', $payload->nonce);
|
||||
self::assertSame('654321', $payload->token);
|
||||
self::assertFalse($payload->json);
|
||||
self::assertSame(Scope::Cookie, $payload->scope);
|
||||
}
|
||||
|
||||
public function testLoadMissingUsernameReturnsNull(): void {
|
||||
$input = new InputBag(['nonce' => 'n', 'totp' => 't']);
|
||||
self::assertNull(Payload::load($input));
|
||||
}
|
||||
|
||||
public function testLoadMissingNonceReturnsNull(): void {
|
||||
$input = new InputBag(['username' => 'u', 'totp' => 't']);
|
||||
self::assertNull(Payload::load($input));
|
||||
}
|
||||
|
||||
public function testLoadMissingTotpReturnsNull(): void {
|
||||
$input = new InputBag(['username' => 'u', 'nonce' => 'n']);
|
||||
self::assertNull(Payload::load($input));
|
||||
}
|
||||
|
||||
public function testCreateWithValidData(): void {
|
||||
$data = (object)[
|
||||
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
|
||||
'json' => false, 'scope' => 'ip',
|
||||
];
|
||||
$payload = Payload::create($data);
|
||||
|
||||
self::assertInstanceOf(Payload::class, $payload);
|
||||
self::assertSame('user1', $payload->id);
|
||||
self::assertSame('tok1', $payload->token);
|
||||
self::assertSame('non1', $payload->nonce);
|
||||
self::assertFalse($payload->json);
|
||||
self::assertSame(Scope::Ip, $payload->scope);
|
||||
}
|
||||
|
||||
public function testCreateWithDefaultScope(): void {
|
||||
$data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
|
||||
$payload = Payload::create($data);
|
||||
self::assertSame(Scope::Cookie, $payload->scope);
|
||||
}
|
||||
|
||||
public function testCreateWithNoneScopeSetsJsonFalse(): void {
|
||||
$data = (object)[
|
||||
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
|
||||
'json' => true, 'scope' => 'none',
|
||||
];
|
||||
$payload = Payload::create($data);
|
||||
self::assertSame(Scope::None, $payload->scope);
|
||||
self::assertFalse($payload->json);
|
||||
}
|
||||
|
||||
public function testCreateWithEmptyIdReturnsNull(): void {
|
||||
$data = (object)['id' => '', 'token' => 't', 'nonce' => 'n'];
|
||||
self::assertNull(Payload::create($data));
|
||||
}
|
||||
|
||||
public function testCreateWithWhitespaceIdReturnsNull(): void {
|
||||
$data = (object)['id' => ' ', 'token' => 't', 'nonce' => 'n'];
|
||||
self::assertNull(Payload::create($data));
|
||||
}
|
||||
|
||||
public function testCreateWithEmptyTokenReturnsNull(): void {
|
||||
$data = (object)['id' => 'u', 'token' => '', 'nonce' => 'n'];
|
||||
self::assertNull(Payload::create($data));
|
||||
}
|
||||
|
||||
public function testCreateWithEmptyNonceReturnsNull(): void {
|
||||
$data = (object)['id' => 'u', 'token' => 't', 'nonce' => ''];
|
||||
self::assertNull(Payload::create($data));
|
||||
}
|
||||
|
||||
public function testCreateTrimsAndTruncatesFields(): void {
|
||||
$long = str_repeat('a', 200);
|
||||
$data = (object)[
|
||||
'id' => ' ' . $long . ' ',
|
||||
'token' => ' ' . $long . ' ',
|
||||
'nonce' => ' ' . $long . ' ',
|
||||
];
|
||||
$payload = Payload::create($data);
|
||||
$expected = mb_substr($long, 0, 128);
|
||||
self::assertSame($expected, $payload->id);
|
||||
self::assertSame($expected, $payload->token);
|
||||
self::assertSame($expected, $payload->nonce);
|
||||
}
|
||||
|
||||
public function testToString(): void {
|
||||
$payload = new Payload();
|
||||
$payload->id = 'u';
|
||||
$payload->token = 't';
|
||||
$payload->nonce = 'n';
|
||||
$payload->json = true;
|
||||
$payload->scope = Scope::Cookie;
|
||||
|
||||
$decoded = json_decode($payload->toString(), true);
|
||||
self::assertSame('u', $decoded['id']);
|
||||
self::assertSame('t', $decoded['token']);
|
||||
self::assertSame('n', $decoded['nonce']);
|
||||
self::assertTrue($decoded['json']);
|
||||
self::assertSame('cookie', $decoded['scope']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Enum;
|
||||
|
||||
use App\Enum\Scope;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ScopeTest extends TestCase {
|
||||
public function testCases(): void {
|
||||
self::assertSame('cookie', Scope::Cookie->value);
|
||||
self::assertSame('ip', Scope::Ip->value);
|
||||
self::assertSame('none', Scope::None->value);
|
||||
}
|
||||
|
||||
public function testTryFromValid(): void {
|
||||
self::assertSame(Scope::Cookie, Scope::tryFrom('cookie'));
|
||||
self::assertSame(Scope::Ip, Scope::tryFrom('ip'));
|
||||
self::assertSame(Scope::None, Scope::tryFrom('none'));
|
||||
}
|
||||
|
||||
public function testTryFromInvalid(): void {
|
||||
self::assertNull(Scope::tryFrom('invalid'));
|
||||
self::assertNull(Scope::tryFrom(''));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit;
|
||||
|
||||
use App\MonitorCacheKeys;
|
||||
use OutOfBoundsException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
|
||||
final class MonitorCacheKeysTest extends TestCase {
|
||||
private function wrap(?ArrayAdapter $pool = null): MonitorCacheKeys {
|
||||
$pool ??= new ArrayAdapter();
|
||||
return new MonitorCacheKeys($pool);
|
||||
}
|
||||
|
||||
public function testConstructorInitializesEmptyPool(): void {
|
||||
$monitor = $this->wrap();
|
||||
|
||||
self::assertSame([], $monitor->getKeys());
|
||||
self::assertSame([], $monitor->getChanges());
|
||||
}
|
||||
|
||||
public function testSaveAddsKeyAndTracksChange(): void {
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('alpha');
|
||||
$item->set('value');
|
||||
$monitor->save($item);
|
||||
|
||||
self::assertSame(['alpha'], $monitor->getKeys());
|
||||
self::assertSame(['alpha' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
|
||||
}
|
||||
|
||||
public function testSaveDeferredThenCommitAddsKey(): void {
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('beta');
|
||||
$item->set('value');
|
||||
$monitor->saveDeferred($item);
|
||||
|
||||
// saveDeferred calls update() which commits immediately
|
||||
self::assertSame(['beta'], $monitor->getKeys());
|
||||
self::assertSame(['beta' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
|
||||
}
|
||||
|
||||
public function testGetItemReturnsUnderlyingItem(): void {
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('mykey');
|
||||
$item->set('data');
|
||||
$monitor->save($item);
|
||||
|
||||
$fetched = $monitor->getItem('mykey');
|
||||
self::assertTrue($fetched->isHit());
|
||||
self::assertSame('data', $fetched->get());
|
||||
}
|
||||
|
||||
public function testGetItemsReturnsMultipleItems(): void {
|
||||
$monitor = $this->wrap();
|
||||
$a = $monitor->getItem('a');
|
||||
$a->set(1);
|
||||
$monitor->save($a);
|
||||
$b = $monitor->getItem('b');
|
||||
$b->set(2);
|
||||
$monitor->save($b);
|
||||
|
||||
$items = $monitor->getItems(['a', 'b']);
|
||||
$keys = [];
|
||||
foreach ($items as $key => $item) {
|
||||
$keys[$key] = $item->get();
|
||||
}
|
||||
self::assertSame(['a' => 1, 'b' => 2], $keys);
|
||||
}
|
||||
|
||||
public function testHasItemReturnsTrueForExistingKey(): void {
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('exists');
|
||||
$item->set('v');
|
||||
$monitor->save($item);
|
||||
|
||||
self::assertTrue($monitor->hasItem('exists'));
|
||||
self::assertFalse($monitor->hasItem('missing'));
|
||||
}
|
||||
|
||||
public function testDeleteItemRemovesKeyAndTracksRemoval(): void {
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('doomed');
|
||||
$item->set('v');
|
||||
$monitor->save($item);
|
||||
|
||||
$monitor->deleteItem('doomed');
|
||||
|
||||
self::assertSame([], $monitor->getKeys());
|
||||
self::assertSame(['doomed' => MonitorCacheKeys::REMOVED], $monitor->getChanges());
|
||||
self::assertFalse($monitor->hasItem('doomed'));
|
||||
}
|
||||
|
||||
public function testDeleteItemOnMissingKeyIsNoop(): void {
|
||||
$monitor = $this->wrap();
|
||||
|
||||
$result = $monitor->deleteItem('nonexistent');
|
||||
|
||||
self::assertTrue($result);
|
||||
self::assertSame([], $monitor->getKeys());
|
||||
}
|
||||
|
||||
public function testDeleteItemsRemovesMultipleKeys(): void {
|
||||
$monitor = $this->wrap();
|
||||
foreach (['x', 'y', 'z'] as $key) {
|
||||
$item = $monitor->getItem($key);
|
||||
$item->set($key);
|
||||
$monitor->save($item);
|
||||
}
|
||||
|
||||
$monitor->deleteItems(['x', 'y']);
|
||||
|
||||
self::assertSame(['z'], $monitor->getKeys());
|
||||
$changes = $monitor->getChanges();
|
||||
self::assertSame(MonitorCacheKeys::REMOVED, $changes['x']);
|
||||
self::assertSame(MonitorCacheKeys::REMOVED, $changes['y']);
|
||||
}
|
||||
|
||||
public function testDeleteItemsWithMissingKeysStillReturnsTrue(): void {
|
||||
$monitor = $this->wrap();
|
||||
|
||||
$result = $monitor->deleteItems(['ghost1', 'ghost2']);
|
||||
|
||||
self::assertTrue($result);
|
||||
}
|
||||
|
||||
public function testClearWipesPoolWhenNotEmpty(): void {
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('keep');
|
||||
$item->set('v');
|
||||
$monitor->save($item);
|
||||
|
||||
$result = $monitor->clear();
|
||||
|
||||
self::assertTrue($result);
|
||||
self::assertSame([], $monitor->getKeys());
|
||||
}
|
||||
|
||||
public function testClearIsNoopWhenEmpty(): void {
|
||||
$monitor = $this->wrap();
|
||||
|
||||
$result = $monitor->clear();
|
||||
|
||||
self::assertTrue($result);
|
||||
}
|
||||
|
||||
public function testMarkCleanResetsChangeList(): void {
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('temp');
|
||||
$item->set('v');
|
||||
$monitor->save($item);
|
||||
|
||||
self::assertNotEmpty($monitor->getChanges());
|
||||
|
||||
$monitor->markClean();
|
||||
|
||||
self::assertSame([], $monitor->getChanges());
|
||||
self::assertSame(['temp'], $monitor->getKeys());
|
||||
}
|
||||
|
||||
public function testCommitPassesThrough(): void {
|
||||
$monitor = $this->wrap();
|
||||
|
||||
self::assertTrue($monitor->commit());
|
||||
}
|
||||
|
||||
public function testSaveKeyListThrowsOutOfBoundsException(): void {
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('__key_list');
|
||||
|
||||
$this->expectException(OutOfBoundsException::class);
|
||||
$monitor->save($item);
|
||||
}
|
||||
|
||||
public function testSaveChangeListThrowsOutOfBoundsException(): void {
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('__chg_list');
|
||||
|
||||
$this->expectException(OutOfBoundsException::class);
|
||||
$monitor->save($item);
|
||||
}
|
||||
|
||||
public function testDeleteKeyListThrowsOutOfBoundsException(): void {
|
||||
$monitor = $this->wrap();
|
||||
|
||||
$this->expectException(OutOfBoundsException::class);
|
||||
$monitor->deleteItem('__key_list');
|
||||
}
|
||||
|
||||
public function testDeleteChangeListThrowsOutOfBoundsException(): void {
|
||||
$monitor = $this->wrap();
|
||||
|
||||
$this->expectException(OutOfBoundsException::class);
|
||||
$monitor->deleteItem('__chg_list');
|
||||
}
|
||||
|
||||
public function testDeleteItemsWithKeyListThrowsOutOfBoundsException(): void {
|
||||
$monitor = $this->wrap();
|
||||
|
||||
$this->expectException(OutOfBoundsException::class);
|
||||
$monitor->deleteItems(['safe', '__key_list']);
|
||||
}
|
||||
|
||||
public function testDeleteItemsWithChangeListThrowsOutOfBoundsException(): void {
|
||||
$monitor = $this->wrap();
|
||||
|
||||
$this->expectException(OutOfBoundsException::class);
|
||||
$monitor->deleteItems(['__chg_list']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit;
|
||||
|
||||
use App\MonitorCacheKeys;
|
||||
use App\PersistCache;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
|
||||
final class PersistCacheTest extends TestCase {
|
||||
public function testBootWithEmptyStorageIsNoop(): void {
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
|
||||
// nothing was loaded since storage is empty
|
||||
$monitor = new MonitorCacheKeys($sessionCache);
|
||||
self::assertSame([], $monitor->getKeys());
|
||||
}
|
||||
|
||||
public function testBootLoadsFromStorageIntoCache(): void {
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
// populate storage with some session data
|
||||
$storageMonitor = new MonitorCacheKeys($sessionStorage);
|
||||
$item = $storageMonitor->getItem('cookie_abc');
|
||||
$item->set('user1');
|
||||
$storageMonitor->save($item);
|
||||
$storageMonitor->markClean();
|
||||
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
|
||||
// session cache should now contain the loaded data
|
||||
$cacheMonitor = new MonitorCacheKeys($sessionCache);
|
||||
self::assertContains('cookie_abc', $cacheMonitor->getKeys());
|
||||
self::assertSame('user1', $cacheMonitor->getItem('cookie_abc')->get());
|
||||
// boot should mark clean so no changes are pending
|
||||
self::assertSame([], $cacheMonitor->getChanges());
|
||||
}
|
||||
|
||||
public function testBootDoesNotReloadWhenCacheAlreadyWarm(): void {
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
// warm up the cache with existing data
|
||||
$cacheMonitor = new MonitorCacheKeys($sessionCache);
|
||||
$item = $cacheMonitor->getItem('cookie_existing');
|
||||
$item->set('old-user');
|
||||
$cacheMonitor->save($item);
|
||||
|
||||
// put different data in storage
|
||||
$storageMonitor = new MonitorCacheKeys($sessionStorage);
|
||||
$item = $storageMonitor->getItem('cookie_new');
|
||||
$item->set('new-user');
|
||||
$storageMonitor->save($item);
|
||||
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
|
||||
// existing data should be preserved, storage data NOT loaded
|
||||
$monitor = new MonitorCacheKeys($sessionCache);
|
||||
self::assertContains('cookie_existing', $monitor->getKeys());
|
||||
self::assertNotContains('cookie_new', $monitor->getKeys());
|
||||
}
|
||||
|
||||
public function testPersistWritesChangesToStorage(): void {
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
|
||||
// write something to the session cache
|
||||
$cacheMonitor = new MonitorCacheKeys($sessionCache);
|
||||
$item = $cacheMonitor->getItem('cookie_xyz');
|
||||
$item->set('user2');
|
||||
$cacheMonitor->save($item);
|
||||
|
||||
$persist->persist();
|
||||
|
||||
// storage should now contain the change
|
||||
$storageMonitor = new MonitorCacheKeys($sessionStorage);
|
||||
self::assertContains('cookie_xyz', $storageMonitor->getKeys());
|
||||
self::assertSame('user2', $storageMonitor->getItem('cookie_xyz')->get());
|
||||
}
|
||||
|
||||
public function testPersistHandlesRemovals(): void {
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
// seed storage with an item
|
||||
$storageMonitor = new MonitorCacheKeys($sessionStorage);
|
||||
$item = $storageMonitor->getItem('cookie_to_remove');
|
||||
$item->set('user3');
|
||||
$storageMonitor->save($item);
|
||||
$storageMonitor->markClean();
|
||||
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
|
||||
// now delete it from session cache
|
||||
$cacheMonitor = new MonitorCacheKeys($sessionCache);
|
||||
$cacheMonitor->deleteItem('cookie_to_remove');
|
||||
|
||||
$persist->persist();
|
||||
|
||||
// storage should no longer have it
|
||||
$storageMonitor = new MonitorCacheKeys($sessionStorage);
|
||||
self::assertNotContains('cookie_to_remove', $storageMonitor->getKeys());
|
||||
}
|
||||
|
||||
public function testPersistIsNoopWhenNoChanges(): void {
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
$persist->persist();
|
||||
|
||||
$storageMonitor = new MonitorCacheKeys($sessionStorage);
|
||||
self::assertSame([], $storageMonitor->getKeys());
|
||||
}
|
||||
|
||||
public function testFullBootModifyPersistCycle(): void {
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
// boot (empty), add data, persist
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
|
||||
$cacheMonitor = new MonitorCacheKeys($sessionCache);
|
||||
$item = $cacheMonitor->getItem('cookie_cycle');
|
||||
$item->set('cycled-user');
|
||||
$cacheMonitor->save($item);
|
||||
|
||||
$persist->persist();
|
||||
|
||||
// simulate a new request: fresh cache, same storage
|
||||
$newCache = new ArrayAdapter();
|
||||
$persist2 = new PersistCache($newCache, $sessionStorage);
|
||||
$persist2->boot();
|
||||
|
||||
$monitor = new MonitorCacheKeys($newCache);
|
||||
self::assertContains('cookie_cycle', $monitor->getKeys());
|
||||
self::assertSame('cycled-user', $monitor->getItem('cookie_cycle')->get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\Service\BackupCodeManager;
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
|
||||
final class BackupCodeManagerTest extends TestCase {
|
||||
use TotpTestHelper;
|
||||
|
||||
private function makeManager(?ArrayAdapter $pool = null): BackupCodeManager {
|
||||
$pool ??= new ArrayAdapter();
|
||||
$manager = new BackupCodeManager($pool);
|
||||
$manager->setConfig($this->makeConfig());
|
||||
$manager->setLogger(new NullLogger());
|
||||
return $manager;
|
||||
}
|
||||
|
||||
public function testGenerateReturnsRequestedCount(): void {
|
||||
$manager = $this->makeManager();
|
||||
|
||||
$codes = $manager->generate(5);
|
||||
|
||||
self::assertCount(5, $codes);
|
||||
foreach ($codes as $code) {
|
||||
self::assertIsString($code);
|
||||
// codes are lowercase alphanumeric
|
||||
self::assertMatchesRegularExpression('/^[a-z0-9]+$/', $code);
|
||||
}
|
||||
}
|
||||
|
||||
public function testGenerateDefaultCount(): void {
|
||||
$manager = $this->makeManager();
|
||||
|
||||
$codes = $manager->generate();
|
||||
|
||||
self::assertCount(10, $codes);
|
||||
}
|
||||
|
||||
public function testGenerateZeroReturnsEmptyArray(): void {
|
||||
$manager = $this->makeManager();
|
||||
|
||||
$codes = $manager->generate(0);
|
||||
|
||||
self::assertSame([], $codes);
|
||||
}
|
||||
|
||||
public function testGeneratedCodesAreStoredInCache(): void {
|
||||
$pool = new ArrayAdapter();
|
||||
$manager = $this->makeManager($pool);
|
||||
|
||||
$codes = $manager->generate(3);
|
||||
|
||||
// each code should be stored as a backup_ key
|
||||
foreach ($codes as $code) {
|
||||
$key = 'backup_' . strtolower($code);
|
||||
// the manager uses makeCacheKey which sanitizes, but for alphanumeric it's identity
|
||||
$item = $pool->getItem($key);
|
||||
self::assertTrue($item->isHit(), "Expected cache hit for key: $key");
|
||||
self::assertTrue($item->get(), "Expected code to be marked valid (true)");
|
||||
}
|
||||
}
|
||||
|
||||
public function testGeneratedCodesHaveFarFutureExpiry(): void {
|
||||
$pool = new ArrayAdapter();
|
||||
$manager = $this->makeManager($pool);
|
||||
|
||||
$codes = $manager->generate(1);
|
||||
$code = $codes[0];
|
||||
|
||||
$item = $pool->getItem('backup_' . strtolower($code));
|
||||
$expiry = $item->getMetadata()['expiry'];
|
||||
self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
|
||||
}
|
||||
|
||||
public function testVerifyAndConsumeValidCode(): void {
|
||||
$manager = $this->makeManager();
|
||||
$codes = $manager->generate(2);
|
||||
|
||||
$code = $codes[0];
|
||||
|
||||
self::assertTrue($manager->verifyAndConsume($code));
|
||||
}
|
||||
|
||||
public function testVerifyAndConsumeMarksCodeAsUsed(): void {
|
||||
$pool = new ArrayAdapter();
|
||||
$manager = $this->makeManager($pool);
|
||||
$codes = $manager->generate(1);
|
||||
$code = $codes[0];
|
||||
|
||||
// first use succeeds
|
||||
self::assertTrue($manager->verifyAndConsume($code));
|
||||
|
||||
// second use fails (already consumed)
|
||||
self::assertFalse($manager->verifyAndConsume($code));
|
||||
}
|
||||
|
||||
public function testVerifyAndConsumeInvalidCode(): void {
|
||||
$manager = $this->makeManager();
|
||||
|
||||
self::assertFalse($manager->verifyAndConsume('nonexistent_code'));
|
||||
}
|
||||
|
||||
public function testVerifyAndConsumeIsCaseInsensitive(): void {
|
||||
$manager = $this->makeManager();
|
||||
$codes = $manager->generate(1);
|
||||
$code = $codes[0];
|
||||
|
||||
// uppercase version should still work
|
||||
self::assertTrue($manager->verifyAndConsume(strtoupper($code)));
|
||||
}
|
||||
|
||||
public function testVerifyAndConsumeStripsInvalidCharacters(): void {
|
||||
$manager = $this->makeManager();
|
||||
$codes = $manager->generate(1);
|
||||
$code = $codes[0];
|
||||
|
||||
// inject spaces and special chars — should be stripped
|
||||
self::assertTrue($manager->verifyAndConsume(' ' . $code . '!!'));
|
||||
}
|
||||
|
||||
public function testExpireRemovesAllBackupCodes(): void {
|
||||
$pool = new ArrayAdapter();
|
||||
$manager = $this->makeManager($pool);
|
||||
$codes = $manager->generate(5);
|
||||
|
||||
$manager->expire();
|
||||
|
||||
// all backup keys should be gone
|
||||
foreach ($codes as $code) {
|
||||
self::assertFalse($pool->hasItem('backup_' . strtolower($code)));
|
||||
}
|
||||
}
|
||||
|
||||
public function testExpireWhenNoBackupCodesIsNoop(): void {
|
||||
$pool = new ArrayAdapter();
|
||||
$manager = $this->makeManager($pool);
|
||||
|
||||
// should not throw
|
||||
$manager->expire();
|
||||
|
||||
// this passes if no exception was thrown
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
public function testExpireRemovesOnlyBackupPrefixedKeys(): void {
|
||||
$pool = new ArrayAdapter();
|
||||
$manager = $this->makeManager($pool);
|
||||
|
||||
$codes = $manager->generate(3);
|
||||
|
||||
// add a non-backup key
|
||||
$item = $pool->getItem('cookie_session');
|
||||
$item->set('data');
|
||||
$pool->save($item);
|
||||
|
||||
$manager->expire();
|
||||
|
||||
// non-backup key survives
|
||||
self::assertTrue($pool->hasItem('cookie_session'));
|
||||
|
||||
// backup keys are gone
|
||||
foreach ($codes as $code) {
|
||||
self::assertFalse($pool->hasItem('backup_' . strtolower($code)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\Service\DomainManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class DomainManagerTest extends TestCase {
|
||||
private function createManager(bool $subdomainRedirect, string $authSubdomain): DomainManager {
|
||||
return new DomainManager($subdomainRedirect, $authSubdomain);
|
||||
}
|
||||
|
||||
/* ── authBase / getAuthSubdomain ─────────────────────────────────────── */
|
||||
|
||||
public function testAuthBaseIsNullWhenSubdomainRedirectIsDisabled(): void {
|
||||
$manager = $this->createManager(false, 'auth.example.com');
|
||||
self::assertNull($manager->authBase());
|
||||
self::assertNull($manager->getAuthSubdomain());
|
||||
}
|
||||
|
||||
public function testAuthBaseIsNullWhenAuthSubdomainIsEmpty(): void {
|
||||
$manager = $this->createManager(true, '');
|
||||
self::assertNull($manager->authBase());
|
||||
self::assertNull($manager->getAuthSubdomain());
|
||||
}
|
||||
|
||||
public function testAuthBaseExtractsSimpleDomain(): void {
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertSame('example.com', $manager->authBase());
|
||||
self::assertSame('auth.example.com', $manager->getAuthSubdomain());
|
||||
}
|
||||
|
||||
public function testAuthBaseExtractsMultiPartTld(): void {
|
||||
$manager = $this->createManager(true, 'auth.example.co.uk');
|
||||
self::assertSame('example.co.uk', $manager->authBase());
|
||||
self::assertSame('auth.example.co.uk', $manager->getAuthSubdomain());
|
||||
}
|
||||
|
||||
public function testAuthBaseIsNullForLocalhostAuth(): void {
|
||||
$manager = $this->createManager(true, 'localhost');
|
||||
self::assertNull($manager->authBase());
|
||||
self::assertNull($manager->getAuthSubdomain());
|
||||
}
|
||||
|
||||
public function testAuthBaseIsNullForIpAuth(): void {
|
||||
$manager = $this->createManager(true, '192.168.1.1');
|
||||
self::assertNull($manager->authBase());
|
||||
self::assertNull($manager->getAuthSubdomain());
|
||||
}
|
||||
|
||||
/* ── validReturn ──────────────────────────────────────────────────────── */
|
||||
|
||||
public function testValidReturnAcceptsAnyUrlWhenNoSubdomain(): void {
|
||||
$manager = $this->createManager(false, '');
|
||||
self::assertTrue($manager->validReturn('https://evil.com/page'));
|
||||
self::assertTrue($manager->validReturn('https://example.com/ok'));
|
||||
}
|
||||
|
||||
public function testValidReturnRejectsInvalidUrl(): void {
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertFalse($manager->validReturn('not-a-url'));
|
||||
self::assertFalse($manager->validReturn(''));
|
||||
}
|
||||
|
||||
public function testValidReturnAcceptsSameBaseDomain(): void {
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertTrue($manager->validReturn('https://app.example.com/dashboard'));
|
||||
self::assertTrue($manager->validReturn('https://example.com/'));
|
||||
}
|
||||
|
||||
public function testValidReturnRejectsDifferentBaseDomain(): void {
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertFalse($manager->validReturn('https://evil.com/phish'));
|
||||
self::assertFalse($manager->validReturn('https://other-example.com/'));
|
||||
}
|
||||
|
||||
public function testValidReturnHandlesCoUkTld(): void {
|
||||
$manager = $this->createManager(true, 'auth.example.co.uk');
|
||||
self::assertTrue($manager->validReturn('https://www.example.co.uk/'));
|
||||
self::assertFalse($manager->validReturn('https://example.com/'));
|
||||
}
|
||||
|
||||
public function testValidReturnRejectsUrlWithoutHost(): void {
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertFalse($manager->validReturn('mailto:test@example.com'));
|
||||
}
|
||||
|
||||
/* ── matchesAuth ──────────────────────────────────────────────────────── */
|
||||
|
||||
public function testMatchesAuthIsFalseWhenSubdomainRedirectDisabled(): void {
|
||||
$manager = $this->createManager(false, 'auth.example.com');
|
||||
self::assertFalse($manager->matchesAuth('example.com'));
|
||||
self::assertFalse($manager->matchesAuth('app.example.com'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthIsFalseWhenAuthSubdomainIsEmpty(): void {
|
||||
$manager = $this->createManager(true, '');
|
||||
self::assertFalse($manager->matchesAuth('example.com'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthMatchesSameBaseDomain(): void {
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertTrue($manager->matchesAuth('example.com'));
|
||||
self::assertTrue($manager->matchesAuth('app.example.com'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthRejectsDifferentBaseDomain(): void {
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertFalse($manager->matchesAuth('evil.com'));
|
||||
self::assertFalse($manager->matchesAuth('example.org'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthHandlesMultiPartTld(): void {
|
||||
$manager = $this->createManager(true, 'auth.example.co.uk');
|
||||
self::assertTrue($manager->matchesAuth('www.example.co.uk'));
|
||||
self::assertFalse($manager->matchesAuth('example.com'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthRejectsIpHost(): void {
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertFalse($manager->matchesAuth('192.168.1.1'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthRejectsLocalhost(): void {
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertFalse($manager->matchesAuth('localhost'));
|
||||
}
|
||||
|
||||
/* ── baseDomain edge cases via matchesAuth ────────────────────────────── */
|
||||
|
||||
public function testMatchesAuthWithDeepSubdomain(): void {
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertTrue($manager->matchesAuth('a.b.c.example.com'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthWithTwoPartDomain(): void {
|
||||
/* for a 2-part auth subdomain, the baseDomain retains both parts */
|
||||
$manager = $this->createManager(true, 'auth.local');
|
||||
self::assertSame('auth.local', $manager->authBase());
|
||||
self::assertTrue($manager->matchesAuth('auth.local'));
|
||||
self::assertFalse($manager->matchesAuth('local'));
|
||||
self::assertFalse($manager->matchesAuth('app.local'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\Data\Payload;
|
||||
use App\Enum\Scope;
|
||||
use App\Service\BackupCodeInterface;
|
||||
use App\Service\DomainManager;
|
||||
use App\Trait\StringTrait;
|
||||
use App\Service\LoginManager;
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
final class LoginManagerTest extends TestCase {
|
||||
use TotpTestHelper;
|
||||
use StringTrait;
|
||||
|
||||
private ArrayAdapter $pool;
|
||||
private BackupCodeInterface $backupCodeManager;
|
||||
private DomainManager $domainManager;
|
||||
|
||||
private function makeLoginManager(
|
||||
?int $ipTtl = 0,
|
||||
bool $subdomainRedirect = false,
|
||||
string $authSubdomain = '',
|
||||
): LoginManager {
|
||||
$this->pool = new ArrayAdapter();
|
||||
$this->backupCodeManager = $this->createStub(BackupCodeInterface::class);
|
||||
$this->domainManager = new DomainManager($subdomainRedirect, $authSubdomain);
|
||||
|
||||
$manager = new LoginManager($this->pool, $this->backupCodeManager, $this->domainManager);
|
||||
$manager->setConfig($this->makeConfig(ipTtl: $ipTtl));
|
||||
$manager->setLogger(new NullLogger());
|
||||
$manager->setNonceCache(new ArrayAdapter());
|
||||
return $manager;
|
||||
}
|
||||
|
||||
/** Build a Payload with a valid server-side nonce already stored. */
|
||||
private function makePayloadWithNonce(
|
||||
LoginManager $manager,
|
||||
string $id = 'testuser',
|
||||
Scope $scope = Scope::Cookie,
|
||||
?string $token = null,
|
||||
): Payload {
|
||||
$token ??= $this->validTotpCode();
|
||||
$nonce = $this->insertNonce($manager, 'test-nonce-123');
|
||||
|
||||
$payload = new Payload();
|
||||
$payload->id = $id;
|
||||
$payload->token = $token;
|
||||
$payload->nonce = $nonce;
|
||||
$payload->json = true;
|
||||
$payload->scope = $scope;
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/** Inject a nonce directly into the manager's nonce cache. */
|
||||
private function insertNonce(LoginManager $manager, string $nonce): string {
|
||||
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache');
|
||||
$nonceCache = $reflection->getValue($manager);
|
||||
|
||||
$key = $this->makeCacheKey($nonce);
|
||||
$item = $nonceCache->getItem($key);
|
||||
$item->set(true);
|
||||
$nonceCache->save($item);
|
||||
|
||||
return $nonce;
|
||||
}
|
||||
|
||||
public function testCheckTokenReturnsNullForInvalidTotp(): void {
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, token: 'wrong-code');
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
self::assertNull($manager->checkToken($payload, $request));
|
||||
}
|
||||
|
||||
public function testCheckTokenReturnsNullForSpentNonce(): void {
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
// spend the nonce first (use the same cache key the manager does)
|
||||
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache');
|
||||
$nonceCache = $reflection->getValue($manager);
|
||||
$nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123'));
|
||||
$nonceItem->set(false);
|
||||
$nonceCache->save($nonceItem);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
self::assertNull($manager->checkToken($payload, $request));
|
||||
}
|
||||
|
||||
public function testCheckTokenReturnsNullForMissingNonce(): void {
|
||||
$manager = $this->makeLoginManager();
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$payload = new Payload();
|
||||
$payload->id = 'user1';
|
||||
$payload->token = $this->validTotpCode();
|
||||
$payload->nonce = 'never-stored';
|
||||
$payload->json = true;
|
||||
$payload->scope = Scope::Cookie;
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
self::assertNull($manager->checkToken($payload, $request));
|
||||
}
|
||||
|
||||
public function testSuccessfulTotpLoginWithCookieScopeReturnsRedirect(): void {
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/dashboard', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
self::assertSame(303, $response->getStatusCode()); // HTTP_SEE_OTHER
|
||||
self::assertTrue($response->headers->has('Location'));
|
||||
self::assertTrue($response->headers->has('Set-Cookie'));
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginWithNoneScopeReturnsPlainResponse(): void {
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::None);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame('text/plain', $response->headers->get('Content-Type'));
|
||||
self::assertTrue($response->headers->has('Remote-User'));
|
||||
// no redirect for Scope::None
|
||||
self::assertFalse($response->headers->has('Location'));
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginSetsRemoteUserHeader(): void {
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, id: 'alice', scope: Scope::None);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
self::assertSame('alice', $response->headers->get('Remote-User'));
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginJsonResponse(): void {
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie, token: null);
|
||||
$payload->json = true;
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/protected', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
self::assertSame('application/json', $response->headers->get('Content-Type'));
|
||||
$body = json_decode($response->getContent(), true);
|
||||
self::assertSame('Login successful', $body['message']);
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginHtmlResponse(): void {
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
||||
$payload->json = false;
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/protected', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginWithReturnUrl(): void {
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/login?return=https://example.com/app', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
self::assertSame('https://example.com/app', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void {
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/login?return=not-a-url', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
$location = $response->headers->get('Location');
|
||||
self::assertStringStartsWith('/login', $location);
|
||||
}
|
||||
|
||||
public function testIpScopeDowngradesToCookieWhenIpAccessDisabled(): void {
|
||||
$manager = $this->makeLoginManager(ipTtl: 0);
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
// Should have a Set-Cookie (downgraded to cookie scope)
|
||||
self::assertNotNull($response);
|
||||
self::assertTrue($response->headers->has('Set-Cookie'));
|
||||
}
|
||||
|
||||
public function testIpScopeWhenEnabledSetsIpSession(): void {
|
||||
$manager = $this->makeLoginManager(ipTtl: 1800);
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
// IP session should be stored; no Set-Cookie for IP scope
|
||||
self::assertFalse($response->headers->has('Set-Cookie'));
|
||||
|
||||
// verify the IP session exists in the cache
|
||||
$reflection = new \ReflectionProperty(LoginManager::class, 'sessionCache');
|
||||
$sessionCache = $reflection->getValue($manager);
|
||||
self::assertTrue($sessionCache->hasItem('ip_1.2.3.4'));
|
||||
}
|
||||
|
||||
public function testBackupCodeAuthentication(): void {
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, token: 'backup-code-123');
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(true);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
self::assertSame(303, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testNonceIsConsumedAfterSuccessfulLogin(): void {
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
$manager->checkToken($payload, $request);
|
||||
|
||||
// nonce should now be marked invalid (false); look it up via the same
|
||||
// cache key the manager uses (makeCacheKey rewrites '-' to '_')
|
||||
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache');
|
||||
$nonceCache = $reflection->getValue($manager);
|
||||
$nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123'));
|
||||
self::assertFalse($nonceItem->get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Trait;
|
||||
|
||||
use App\Trait\CookieNameTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CookieNameTraitTest extends TestCase {
|
||||
use CookieNameTrait;
|
||||
|
||||
public function testCookieName(): void {
|
||||
self::assertSame('__Host-Http-Preauth', $this->cookieName());
|
||||
}
|
||||
|
||||
public function testAuthCookieName(): void {
|
||||
self::assertSame('__Http-Domain-Preauth', $this->authCookieName());
|
||||
}
|
||||
|
||||
public function testHeaderName(): void {
|
||||
self::assertSame('X-Preauth', $this->headerName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Trait;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
use App\Trait\GetTotpTrait;
|
||||
use OTPHP\TOTPInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
final class GetTotpTraitTest extends TestCase {
|
||||
use TotpTestHelper;
|
||||
|
||||
private function makeObject(): object {
|
||||
return new class {
|
||||
use GetTotpTrait;
|
||||
|
||||
public function publicGetTotp(): TOTPInterface {
|
||||
return $this->getTotp();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function testSetConfigSetsProperty(): void {
|
||||
$obj = $this->makeObject();
|
||||
$config = $this->makeConfig();
|
||||
|
||||
$obj->setConfig($config);
|
||||
|
||||
$reflection = new \ReflectionProperty($obj, 'config');
|
||||
self::assertSame($config, $reflection->getValue($obj));
|
||||
}
|
||||
|
||||
public function testGetTotpReturnsTotpInterface(): void {
|
||||
$obj = $this->makeObject();
|
||||
$obj->setConfig($this->makeConfig());
|
||||
|
||||
$totp = $obj->publicGetTotp();
|
||||
|
||||
self::assertInstanceOf(TOTPInterface::class, $totp);
|
||||
}
|
||||
|
||||
public function testGetTotpReturnsValidCode(): void {
|
||||
$obj = $this->makeObject();
|
||||
$obj->setConfig($this->makeConfig());
|
||||
|
||||
$totp = $obj->publicGetTotp();
|
||||
|
||||
// the code at the frozen time should match our helper
|
||||
self::assertSame($this->validTotpCode(), $totp->now());
|
||||
}
|
||||
|
||||
public function testGetTotpThrowsOnInvalidUri(): void {
|
||||
$obj = $this->makeObject();
|
||||
$clock = $this->frozenClock();
|
||||
$utilities = $this->createUtilities($clock);
|
||||
$config = new ConfigBag(
|
||||
$utilities, $clock,
|
||||
3600, 'not-a-valid-uri', 0, false,
|
||||
'Error', 'Teapot', 'Too Many'
|
||||
);
|
||||
$obj->setConfig($config);
|
||||
|
||||
// Factory::loadFromProvisioningUri throws InvalidProvisioningUriException
|
||||
// which is not caught by getTotp() since the instanceof check only runs
|
||||
// after a successful load — so we expect a Throwable here
|
||||
$this->expectException(\Throwable::class);
|
||||
$obj->publicGetTotp();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Trait;
|
||||
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class HasLoggerTraitTest extends TestCase {
|
||||
use HasLoggerTrait;
|
||||
|
||||
public function testSetLogger(): void {
|
||||
$logger = $this->createStub(LoggerInterface::class);
|
||||
$this->setLogger($logger);
|
||||
self::assertSame($logger, $this->logger);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Trait;
|
||||
|
||||
use App\Trait\MakeNonceTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
/**
|
||||
* Wraps the trait in a concrete class with public proxies so the protected
|
||||
* methods can be exercised from test scope.
|
||||
*/
|
||||
final class MakeNonceTraitTest extends TestCase {
|
||||
private function makeObject(): object {
|
||||
return new class {
|
||||
use MakeNonceTrait;
|
||||
|
||||
public function publicMakeNonce(int $retries = 3): string {
|
||||
return $this->makeNonce($retries);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function testMakeNonceReturnsBase64UrlString(): void {
|
||||
$obj = $this->makeObject();
|
||||
$obj->setLogger(new NullLogger());
|
||||
$obj->setNonceCache(new ArrayAdapter());
|
||||
|
||||
$nonce = $obj->publicMakeNonce();
|
||||
|
||||
self::assertIsString($nonce);
|
||||
// 15 bytes -> 20 base64 chars without padding
|
||||
self::assertSame(20, strlen($nonce));
|
||||
// base64url charset only
|
||||
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonce);
|
||||
}
|
||||
|
||||
public function testMakeNonceStoresNonceInCache(): void {
|
||||
$pool = new ArrayAdapter();
|
||||
$obj = $this->makeObject();
|
||||
$obj->setLogger(new NullLogger());
|
||||
$obj->setNonceCache($pool);
|
||||
|
||||
$nonce = $obj->publicMakeNonce();
|
||||
|
||||
self::assertTrue($pool->hasItem($nonce));
|
||||
$item = $pool->getItem($nonce);
|
||||
self::assertTrue($item->get());
|
||||
}
|
||||
|
||||
public function testMakeNonceSetsExpiry(): void {
|
||||
$pool = new ArrayAdapter();
|
||||
$obj = $this->makeObject();
|
||||
$obj->setLogger(new NullLogger());
|
||||
$obj->setNonceCache($pool);
|
||||
|
||||
$nonce = $obj->publicMakeNonce();
|
||||
|
||||
$item = $pool->getItem($nonce);
|
||||
$expiry = $item->getMetadata()['expiry'];
|
||||
// NONCE_TTL is 120 seconds
|
||||
self::assertLessThanOrEqual(120, (int) $expiry - time());
|
||||
self::assertGreaterThan(time(), (int) $expiry);
|
||||
}
|
||||
|
||||
public function testTwoNoncesAreDifferent(): void {
|
||||
$pool = new ArrayAdapter();
|
||||
$obj = $this->makeObject();
|
||||
$obj->setLogger(new NullLogger());
|
||||
$obj->setNonceCache($pool);
|
||||
|
||||
$nonce1 = $obj->publicMakeNonce();
|
||||
$nonce2 = $obj->publicMakeNonce();
|
||||
|
||||
self::assertNotSame($nonce1, $nonce2);
|
||||
}
|
||||
|
||||
public function testMakeNonceThrowsAfterMaxRetries(): void {
|
||||
// Create a stub pool that always reports every key as a hit (collision)
|
||||
$pool = $this->createStub(CacheItemPoolInterface::class);
|
||||
$item = $this->createStub(CacheItemInterface::class);
|
||||
$item->method('isHit')->willReturn(true);
|
||||
$item->method('get')->willReturn(true);
|
||||
$pool->method('getItem')->willReturn($item);
|
||||
$pool->method('save')->willReturn(true);
|
||||
|
||||
$obj = $this->makeObject();
|
||||
$obj->setLogger(new NullLogger());
|
||||
$obj->setNonceCache($pool);
|
||||
|
||||
$this->expectException(HttpException::class);
|
||||
$this->expectExceptionMessage('Internal Server Error');
|
||||
|
||||
$obj->publicMakeNonce();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Trait;
|
||||
|
||||
use App\Trait\StringTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class StringTraitTest extends TestCase {
|
||||
use StringTrait;
|
||||
|
||||
public function testMakeCacheKeySanitizesInvalidChars(): void {
|
||||
self::assertSame('hello_world', $this->makeCacheKey('hello world'));
|
||||
self::assertSame('hello_world', $this->makeCacheKey('hello!world'));
|
||||
self::assertSame('a_b_c_d', $this->makeCacheKey('a/b@c#d'));
|
||||
}
|
||||
|
||||
public function testMakeCacheKeyPreservesValidChars(): void {
|
||||
self::assertSame('ABC_123.abc', $this->makeCacheKey('ABC_123.abc'));
|
||||
}
|
||||
|
||||
public function testMakeCacheKeyTruncatesLongNames(): void {
|
||||
$long = str_repeat('a', 300);
|
||||
$result = $this->makeCacheKey($long);
|
||||
self::assertSame(128, mb_strlen($result));
|
||||
}
|
||||
|
||||
public function testMakeCacheKeyEmptyString(): void {
|
||||
self::assertSame('', $this->makeCacheKey(''));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit;
|
||||
|
||||
use App\Utilities;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Clock\ClockInterface;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
|
||||
final class UtilitiesTest extends TestCase {
|
||||
private function makeUtilities(?ArrayAdapter $pool = null, ?ClockInterface $clock = null): Utilities {
|
||||
$pool ??= new ArrayAdapter();
|
||||
$clock ??= $this->createStub(ClockInterface::class);
|
||||
return new Utilities($clock, $pool);
|
||||
}
|
||||
|
||||
public function testLoadTotpReturnsCachedValueWhenPresent(): void {
|
||||
$pool = new ArrayAdapter();
|
||||
$item = $pool->getItem('totp');
|
||||
$item->set('otpauth://totp/cached?secret=ABCDEFGH');
|
||||
$pool->save($item);
|
||||
|
||||
$utilities = $this->makeUtilities($pool);
|
||||
|
||||
$result = $utilities->loadTotp();
|
||||
|
||||
self::assertSame('otpauth://totp/cached?secret=ABCDEFGH', $result);
|
||||
}
|
||||
|
||||
public function testLoadTotpGeneratesAndStoresWhenMissing(): void {
|
||||
$pool = new ArrayAdapter();
|
||||
$utilities = $this->makeUtilities($pool);
|
||||
|
||||
$result = $utilities->loadTotp();
|
||||
|
||||
self::assertNotEmpty($result);
|
||||
self::assertStringStartsWith('otpauth://totp/', $result);
|
||||
|
||||
// stored in cache for next boot
|
||||
$cached = $pool->getItem('totp');
|
||||
self::assertTrue($cached->isHit());
|
||||
self::assertSame($result, $cached->get());
|
||||
}
|
||||
|
||||
public function testLoadTotpSetsFarFutureExpiry(): void {
|
||||
$pool = new ArrayAdapter();
|
||||
$utilities = $this->makeUtilities($pool);
|
||||
|
||||
$utilities->loadTotp();
|
||||
|
||||
$cached = $pool->getItem('totp');
|
||||
$expiry = $cached->getMetadata()['expiry'];
|
||||
// 2999-12-31 is well in the future, far beyond any reasonable test timestamp
|
||||
self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
|
||||
}
|
||||
|
||||
public function testLoadTotpIsIdempotentAfterGeneration(): void {
|
||||
$pool = new ArrayAdapter();
|
||||
$utilities = $this->makeUtilities($pool);
|
||||
|
||||
$first = $utilities->loadTotp();
|
||||
|
||||
// second call should find it in cache and return the same value
|
||||
$second = $utilities->loadTotp();
|
||||
|
||||
self::assertSame($first, $second);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
use Symfony\Component\Dotenv\Dotenv;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
|
||||
if (method_exists(Dotenv::class, 'bootEnv')) {
|
||||
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env.test');
|
||||
}
|
||||
|
||||
if (!isset($_SERVER['APP_DEBUG'])) {
|
||||
$_SERVER['APP_DEBUG'] = false;
|
||||
}
|
||||
Reference in New Issue
Block a user