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:
2026-08-05 16:52:44 -04:00
parent 3c1253ee45
commit 890cc225ef
24 changed files with 3988 additions and 3 deletions
+26
View File
@@ -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
View File
@@ -6,3 +6,9 @@
/vendor/
###< symfony/framework-bundle ###
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
/bin/.phpunit.result.cache
###< phpunit/phpunit ###
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env php
<?php
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
+10
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -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>
+15
View File
@@ -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": {
+89
View File
@@ -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);
}
}
+20
View File
@@ -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());
}
}
+90
View File
@@ -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());
}
}
+152
View File
@@ -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']);
}
}
+26
View File
@@ -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(''));
}
}
+212
View File
@@ -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']);
}
}
+153
View File
@@ -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)));
}
}
}
+145
View File
@@ -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'));
}
}
+296
View File
@@ -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());
}
}
+23
View File
@@ -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());
}
}
+72
View File
@@ -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();
}
}
+18
View File
@@ -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);
}
}
+101
View File
@@ -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();
}
}
+31
View File
@@ -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(''));
}
}
+69
View File
@@ -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);
}
}
+13
View File
@@ -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;
}