PayloadTest: - decode() with invalid JSON, JSON array, null, boolean, number - load() with all fields present but empty strings - create() with invalid scope string (falls back to cookie) - create() with missing json property (defaults to true) DomainManagerTest: - TLD table coverage: com.au, co.jp, com.br, co.nz, com.mx, co.in, br.com - Single-label host matching behavior - Empty string host - validReturn with URL containing port, without path, different domain with port MakeNonceTraitTest: - Retry-then-succeed path (first attempt collides, second succeeds) - Zero retries throws immediately on collision StringTraitTest: - Only invalid characters collapses to single underscore - Exactly 128 characters boundary - Multibyte characters (é, ö) replaced with underscore - Emoji replaced with underscore GetTotpTraitTest: - HOTP URI triggers HttpException(500) via instanceof check LoginManagerTest: - ULID collision throws HttpException(500) - Central auth cookie sets domain on matching host (uses auth cookie name) - Central auth cookie uses null domain on non-matching host - Empty return parameter falls back to request path BackupCodeManagerTest: - Empty string code returns false - Code with value false (consumed) verified via cache state - Generated codes are unique (50 codes) - Code length equals TOTP digits + 2 MonitorCacheKeysTest: - saveDeferred on __key_list and __chg_list throws OutOfBoundsException - getKeys returns empty when key list is lost - deleteItem/deleteItems return true assertions PersistCacheTest: - Mixed updates and removals in same persist cycle - Multiple boot-modify-persist cycles with interleaved modifications
This commit is contained in:
@@ -36,6 +36,28 @@ final class PayloadTest extends TestCase {
|
||||
self::assertNull(Payload::decode(self::b64u('"just a string"')));
|
||||
}
|
||||
|
||||
public function testDecodeInvalidJsonReturnsNull(): void {
|
||||
// valid base64url but invalid JSON
|
||||
self::assertNull(Payload::decode(self::b64u('{invalid json')));
|
||||
}
|
||||
|
||||
public function testDecodeJsonArrayReturnsNull(): void {
|
||||
self::assertNull(Payload::decode(self::b64u('[1,2,3]')));
|
||||
}
|
||||
|
||||
public function testDecodeJsonNullReturnsNull(): void {
|
||||
self::assertNull(Payload::decode(self::b64u('null')));
|
||||
}
|
||||
|
||||
public function testDecodeJsonBooleanReturnsNull(): void {
|
||||
self::assertNull(Payload::decode(self::b64u('true')));
|
||||
self::assertNull(Payload::decode(self::b64u('false')));
|
||||
}
|
||||
|
||||
public function testDecodeJsonNumberReturnsNull(): void {
|
||||
self::assertNull(Payload::decode(self::b64u('42')));
|
||||
}
|
||||
|
||||
public function testDecodeEmptyStringReturnsNull(): void {
|
||||
self::assertNull(Payload::decode(''));
|
||||
}
|
||||
@@ -69,6 +91,12 @@ final class PayloadTest extends TestCase {
|
||||
self::assertNull(Payload::load($input));
|
||||
}
|
||||
|
||||
public function testLoadWithAllFieldsPresentButEmptyReturnsNull(): void {
|
||||
// has() returns true for all, but create() rejects empty values
|
||||
$input = new InputBag(['username' => '', 'nonce' => '', 'totp' => '']);
|
||||
self::assertNull(Payload::load($input));
|
||||
}
|
||||
|
||||
public function testCreateWithValidData(): void {
|
||||
$data = (object)[
|
||||
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
|
||||
@@ -90,6 +118,21 @@ final class PayloadTest extends TestCase {
|
||||
self::assertSame(Scope::Cookie, $payload->scope);
|
||||
}
|
||||
|
||||
public function testCreateWithInvalidScopeFallsBackToCookie(): void {
|
||||
$data = (object)[
|
||||
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
|
||||
'scope' => 'admin',
|
||||
];
|
||||
$payload = Payload::create($data);
|
||||
self::assertSame(Scope::Cookie, $payload->scope);
|
||||
}
|
||||
|
||||
public function testCreateWithMissingJsonDefaultsToTrue(): void {
|
||||
$data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
|
||||
$payload = Payload::create($data);
|
||||
self::assertTrue($payload->json);
|
||||
}
|
||||
|
||||
public function testCreateWithNoneScopeSetsJsonFalse(): void {
|
||||
$data = (object)[
|
||||
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
|
||||
|
||||
@@ -209,4 +209,65 @@ final class MonitorCacheKeysTest extends TestCase {
|
||||
$this->expectException(OutOfBoundsException::class);
|
||||
$monitor->deleteItems(['__chg_list']);
|
||||
}
|
||||
|
||||
public function testSaveDeferredOnKeyListThrowsOutOfBoundsException(): void {
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('safe');
|
||||
$item->set('value');
|
||||
|
||||
// getItem returns the real item, but saveDeferred calls update() which
|
||||
// validates the key — so we need to get the __key_list item and try to save it
|
||||
$keyListItem = $monitor->getItem('__key_list');
|
||||
|
||||
$this->expectException(OutOfBoundsException::class);
|
||||
$monitor->saveDeferred($keyListItem);
|
||||
}
|
||||
|
||||
public function testSaveDeferredOnChangeListThrowsOutOfBoundsException(): void {
|
||||
$monitor = $this->wrap();
|
||||
$changeListItem = $monitor->getItem('__chg_list');
|
||||
|
||||
$this->expectException(OutOfBoundsException::class);
|
||||
$monitor->saveDeferred($changeListItem);
|
||||
}
|
||||
|
||||
public function testGetKeysReturnsEmptyArrayWhenKeyListMissing(): void {
|
||||
// If the underlying pool loses its key list, getKeys should return []
|
||||
$pool = new ArrayAdapter();
|
||||
$monitor = new MonitorCacheKeys($pool);
|
||||
|
||||
$item = $monitor->getItem('alpha');
|
||||
$item->set('value');
|
||||
$monitor->save($item);
|
||||
|
||||
// delete the key list directly from the underlying pool
|
||||
$pool->deleteItem('__key_list');
|
||||
|
||||
$monitor2 = new MonitorCacheKeys($pool);
|
||||
// the constructor will re-initialize since __key_list is missing
|
||||
// but getKeys on the new monitor should be empty
|
||||
self::assertSame([], $monitor2->getKeys());
|
||||
}
|
||||
|
||||
public function testDeleteItemReturnsTrueForExistingKey(): void {
|
||||
$monitor = $this->wrap();
|
||||
$item = $monitor->getItem('to-delete');
|
||||
$item->set('value');
|
||||
$monitor->save($item);
|
||||
|
||||
self::assertTrue($monitor->deleteItem('to-delete'));
|
||||
self::assertNotContains('to-delete', $monitor->getKeys());
|
||||
}
|
||||
|
||||
public function testDeleteItemsReturnsTrue(): void {
|
||||
$monitor = $this->wrap();
|
||||
foreach (['a', 'b', 'c'] as $key) {
|
||||
$item = $monitor->getItem($key);
|
||||
$item->set('value');
|
||||
$monitor->save($item);
|
||||
}
|
||||
|
||||
self::assertTrue($monitor->deleteItems(['a', 'b', 'c']));
|
||||
self::assertSame([], $monitor->getKeys());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,4 +150,71 @@ final class PersistCacheTest extends TestCase {
|
||||
self::assertContains('cookie_cycle', $monitor->getKeys());
|
||||
self::assertSame('cycled-user', $monitor->getItem('cookie_cycle')->get());
|
||||
}
|
||||
|
||||
public function testPersistHandlesMixedUpdatesAndRemovals(): void {
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
// seed storage with two items
|
||||
$storageMonitor = new MonitorCacheKeys($sessionStorage);
|
||||
$item1 = $storageMonitor->getItem('cookie_keep');
|
||||
$item1->set('user-keep');
|
||||
$storageMonitor->save($item1);
|
||||
$item2 = $storageMonitor->getItem('cookie_remove');
|
||||
$item2->set('user-remove');
|
||||
$storageMonitor->save($item2);
|
||||
$storageMonitor->markClean();
|
||||
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
|
||||
// update one item and delete the other in the same cycle
|
||||
$cacheMonitor = new MonitorCacheKeys($sessionCache);
|
||||
$item1 = $cacheMonitor->getItem('cookie_keep');
|
||||
$item1->set('user-updated');
|
||||
$cacheMonitor->save($item1);
|
||||
$cacheMonitor->deleteItem('cookie_remove');
|
||||
|
||||
$persist->persist();
|
||||
|
||||
// storage should reflect both changes
|
||||
$storageMonitor = new MonitorCacheKeys($sessionStorage);
|
||||
self::assertContains('cookie_keep', $storageMonitor->getKeys());
|
||||
self::assertSame('user-updated', $storageMonitor->getItem('cookie_keep')->get());
|
||||
self::assertNotContains('cookie_remove', $storageMonitor->getKeys());
|
||||
}
|
||||
|
||||
public function testMultipleBootModifyPersistCycles(): void {
|
||||
$sessionCache = new ArrayAdapter();
|
||||
$sessionStorage = new ArrayAdapter();
|
||||
|
||||
// cycle 1: add item A
|
||||
$persist = new PersistCache($sessionCache, $sessionStorage);
|
||||
$persist->boot();
|
||||
$cacheMonitor = new MonitorCacheKeys($sessionCache);
|
||||
$item = $cacheMonitor->getItem('cookie_a');
|
||||
$item->set('user-a');
|
||||
$cacheMonitor->save($item);
|
||||
$persist->persist();
|
||||
|
||||
// cycle 2: fresh cache, add item B, keep A from storage
|
||||
$newCache = new ArrayAdapter();
|
||||
$persist2 = new PersistCache($newCache, $sessionStorage);
|
||||
$persist2->boot();
|
||||
$cacheMonitor2 = new MonitorCacheKeys($newCache);
|
||||
$item = $cacheMonitor2->getItem('cookie_b');
|
||||
$item->set('user-b');
|
||||
$cacheMonitor2->save($item);
|
||||
$persist2->persist();
|
||||
|
||||
// cycle 3: fresh cache, both A and B should be loaded from storage
|
||||
$newCache2 = new ArrayAdapter();
|
||||
$persist3 = new PersistCache($newCache2, $sessionStorage);
|
||||
$persist3->boot();
|
||||
$monitor = new MonitorCacheKeys($newCache2);
|
||||
self::assertContains('cookie_a', $monitor->getKeys());
|
||||
self::assertSame('user-a', $monitor->getItem('cookie_a')->get());
|
||||
self::assertContains('cookie_b', $monitor->getKeys());
|
||||
self::assertSame('user-b', $monitor->getItem('cookie_b')->get());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,4 +168,48 @@ final class BackupCodeManagerTest extends TestCase {
|
||||
self::assertFalse($pool->hasItem('backup_' . strtolower($code)));
|
||||
}
|
||||
}
|
||||
|
||||
public function testVerifyAndConsumeEmptyStringReturnsFalse(): void {
|
||||
$manager = $this->makeManager();
|
||||
|
||||
// empty string after preg_replace becomes 'backup_' with nothing after it
|
||||
self::assertFalse($manager->verifyAndConsume(''));
|
||||
}
|
||||
|
||||
public function testVerifyAndConsumeCodeWithValueFalseReturnsFalse(): void {
|
||||
$pool = new ArrayAdapter();
|
||||
$manager = $this->makeManager($pool);
|
||||
$codes = $manager->generate(1);
|
||||
$code = $codes[0];
|
||||
|
||||
// first use succeeds
|
||||
self::assertTrue($manager->verifyAndConsume($code));
|
||||
|
||||
// the code is now marked as false (used); isHit is true but get() is false
|
||||
$key = 'backup_' . strtolower($code);
|
||||
$item = $pool->getItem($key);
|
||||
self::assertTrue($item->isHit());
|
||||
self::assertFalse($item->get());
|
||||
|
||||
// second use should fail because get() returns false
|
||||
self::assertFalse($manager->verifyAndConsume($code));
|
||||
}
|
||||
|
||||
public function testGenerateProducesUniqueCodes(): void {
|
||||
$manager = $this->makeManager();
|
||||
|
||||
$codes = $manager->generate(50);
|
||||
|
||||
self::assertCount(50, $codes);
|
||||
self::assertCount(50, array_unique($codes), 'All generated codes should be unique');
|
||||
}
|
||||
|
||||
public function testGenerateCodeLengthIsDigitsPlusTwo(): void {
|
||||
$manager = $this->makeManager();
|
||||
|
||||
$codes = $manager->generate(1);
|
||||
|
||||
// default TOTP digits is 6, so code length should be 6 + 2 = 8
|
||||
self::assertSame(8, strlen($codes[0]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,4 +142,100 @@ final class DomainManagerTest extends TestCase {
|
||||
self::assertFalse($manager->matchesAuth('local'));
|
||||
self::assertFalse($manager->matchesAuth('app.local'));
|
||||
}
|
||||
|
||||
/* ── TLD table coverage ──────────────────────────────────────────────── */
|
||||
|
||||
public function testMatchesAuthWithComAuTld(): void {
|
||||
// com.au is NOT in the TLD table (table has au? no, it doesn't),
|
||||
// so it's treated as a standard 2-part TLD: base = com.au
|
||||
$manager = $this->createManager(true, 'auth.example.com.au');
|
||||
self::assertSame('com.au', $manager->authBase());
|
||||
self::assertTrue($manager->matchesAuth('app.example.com.au'));
|
||||
self::assertFalse($manager->matchesAuth('example.com'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthWithCoJpTld(): void {
|
||||
// co.jp is NOT in the TLD table (table has jpn under com, not jp under co)
|
||||
// so base = co.jp
|
||||
$manager = $this->createManager(true, 'auth.example.co.jp');
|
||||
self::assertSame('co.jp', $manager->authBase());
|
||||
self::assertTrue($manager->matchesAuth('www.example.co.jp'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthWithComBrTld(): void {
|
||||
// com.br: TLD table has com => [br], meaning *.br.com is multi-part
|
||||
// but com.br has last=br, TLD['br'] doesn't exist, so base = com.br
|
||||
$manager = $this->createManager(true, 'auth.example.com.br');
|
||||
self::assertSame('com.br', $manager->authBase());
|
||||
self::assertTrue($manager->matchesAuth('app.example.com.br'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthWithCoNzTld(): void {
|
||||
// co.nz is NOT in the TLD table (nz => [co,net,org], so *.co.nz IS multi-part)
|
||||
$manager = $this->createManager(true, 'auth.example.co.nz');
|
||||
self::assertSame('example.co.nz', $manager->authBase());
|
||||
self::assertTrue($manager->matchesAuth('sub.example.co.nz'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthWithComMxTld(): void {
|
||||
// com.mx is NOT in the TLD table (mx => [com,net,org], so *.com.mx IS multi-part)
|
||||
$manager = $this->createManager(true, 'auth.example.com.mx');
|
||||
self::assertSame('example.com.mx', $manager->authBase());
|
||||
self::assertTrue($manager->matchesAuth('app.example.com.mx'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthWithCoInTld(): void {
|
||||
// co.in: in => [co,...], so *.co.in IS multi-part
|
||||
$manager = $this->createManager(true, 'auth.example.co.in');
|
||||
self::assertSame('example.co.in', $manager->authBase());
|
||||
self::assertTrue($manager->matchesAuth('app.example.co.in'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthWithBrComTld(): void {
|
||||
// br.com: TLD table has com => [br], so *.br.com IS multi-part
|
||||
$manager = $this->createManager(true, 'auth.example.br.com');
|
||||
self::assertSame('example.br.com', $manager->authBase());
|
||||
self::assertTrue($manager->matchesAuth('app.example.br.com'));
|
||||
}
|
||||
|
||||
public function testSimpleTldNotTreatedAsMultiPart(): void {
|
||||
// example.com is a standard 2-part domain, not multi-part
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertSame('example.com', $manager->authBase());
|
||||
// auth.example.org should NOT match example.com
|
||||
self::assertFalse($manager->matchesAuth('app.example.org'));
|
||||
}
|
||||
|
||||
/* ── baseDomain edge cases ───────────────────────────────────────────── */
|
||||
|
||||
public function testMatchesAuthWithSingleLabelHost(): void {
|
||||
// a single-label domain (not localhost, not IP) has baseLength 1
|
||||
// so 'myhost' has baseDomain 'myhost', while 'auth.local' has base 'auth.local'
|
||||
// they won't match unless the auth subdomain itself is single-label
|
||||
$manager = $this->createManager(true, 'auth.local');
|
||||
// auth.local base is 'auth.local', 'local' base is 'local' -> no match
|
||||
self::assertFalse($manager->matchesAuth('local'));
|
||||
// but a subdomain of auth.local does match
|
||||
self::assertTrue($manager->matchesAuth('app.auth.local'));
|
||||
}
|
||||
|
||||
public function testMatchesAuthWithEmptyStringHost(): void {
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertFalse($manager->matchesAuth(''));
|
||||
}
|
||||
|
||||
public function testValidReturnAcceptsUrlWithPort(): void {
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertTrue($manager->validReturn('https://example.com:8080/path'));
|
||||
}
|
||||
|
||||
public function testValidReturnAcceptsUrlWithoutPath(): void {
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertTrue($manager->validReturn('https://example.com'));
|
||||
}
|
||||
|
||||
public function testValidReturnRejectsDifferentDomainWithPort(): void {
|
||||
$manager = $this->createManager(true, 'auth.example.com');
|
||||
self::assertFalse($manager->validReturn('https://evil.com:8080/path'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ use App\Trait\StringTrait;
|
||||
use App\Service\LoginManager;
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
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\HttpFoundation\Request;
|
||||
@@ -293,4 +295,130 @@ final class LoginManagerTest extends TestCase {
|
||||
$nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123'));
|
||||
self::assertFalse($nonceItem->get());
|
||||
}
|
||||
|
||||
public function testUlidCollisionThrowsHttpException(): void {
|
||||
// Use a stub pool where every cookie_ key is already a hit (collision)
|
||||
$pool = $this->createStub(CacheItemPoolInterface::class);
|
||||
$item = $this->createStub(CacheItemInterface::class);
|
||||
$item->method('isHit')->willReturn(true);
|
||||
$item->method('get')->willReturn('existing');
|
||||
// The nonce cache needs to work, so we return the stub item for
|
||||
// cookie_ keys but a real working item for nonce keys.
|
||||
$pool->method('getItem')->willReturnCallback(function (string $key) use ($item) {
|
||||
if (str_starts_with($key, 'cookie_')) {
|
||||
return $item; // collision
|
||||
}
|
||||
// For nonce keys, return a real item from an ArrayAdapter
|
||||
static $realPool = null;
|
||||
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
|
||||
return $realPool->getItem($key);
|
||||
});
|
||||
$pool->method('hasItem')->willReturnCallback(function (string $key) use ($item) {
|
||||
if (str_starts_with($key, 'cookie_')) {
|
||||
return true;
|
||||
}
|
||||
static $realPool = null;
|
||||
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
|
||||
return $realPool->hasItem($key);
|
||||
});
|
||||
$pool->method('save')->willReturn(true);
|
||||
$pool->method('saveDeferred')->willReturn(true);
|
||||
$pool->method('commit')->willReturn(true);
|
||||
$pool->method('getItems')->willReturnCallback(function (array $keys) {
|
||||
static $realPool = null;
|
||||
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
|
||||
return $realPool->getItems($keys);
|
||||
});
|
||||
$pool->method('clear')->willReturn(true);
|
||||
$pool->method('deleteItem')->willReturn(true);
|
||||
$pool->method('deleteItems')->willReturn(true);
|
||||
|
||||
$this->domainManager = new DomainManager(false, '');
|
||||
$this->backupCodeManager = $this->createStub(BackupCodeInterface::class);
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$manager = new LoginManager($pool, $this->backupCodeManager, $this->domainManager);
|
||||
$manager->setConfig($this->makeConfig());
|
||||
$manager->setLogger(new NullLogger());
|
||||
$manager->setNonceCache(new \Symfony\Component\Cache\Adapter\ArrayAdapter());
|
||||
|
||||
$payload = new Payload();
|
||||
$payload->id = 'collide-user';
|
||||
$payload->token = $this->validTotpCode();
|
||||
$payload->nonce = 'test-nonce-123';
|
||||
$payload->json = true;
|
||||
$payload->scope = Scope::Cookie;
|
||||
|
||||
// inject the nonce
|
||||
$this->insertNonce($manager, 'test-nonce-123');
|
||||
|
||||
$request = Request::create('/', 'GET');
|
||||
|
||||
$this->expectException(HttpException::class);
|
||||
$manager->checkToken($payload, $request);
|
||||
}
|
||||
|
||||
public function testCookieScopeWithCentralAuthSetsDomainOnMatchingHost(): void {
|
||||
$manager = $this->makeLoginManager(
|
||||
subdomainRedirect: true,
|
||||
authSubdomain: 'auth.example.com',
|
||||
);
|
||||
$payload = $this->makePayloadWithNonce($manager, id: 'alice', scope: Scope::Cookie);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
// host matches the auth base domain
|
||||
$request = Request::create('https://auth.example.com/', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
$cookies = $response->headers->getCookies();
|
||||
self::assertCount(1, $cookies);
|
||||
// when using central auth and host matches, the cookie domain is set
|
||||
self::assertSame('example.com', $cookies[0]->getDomain());
|
||||
// the auth cookie name is used instead of the host-prefixed name
|
||||
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
|
||||
}
|
||||
|
||||
public function testCookieScopeWithCentralAuthOnNonMatchingHostUsesNullDomain(): void {
|
||||
$manager = $this->makeLoginManager(
|
||||
subdomainRedirect: true,
|
||||
authSubdomain: 'auth.example.com',
|
||||
);
|
||||
$payload = $this->makePayloadWithNonce($manager, id: 'bob', scope: Scope::Cookie);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
// host does NOT match the auth base domain
|
||||
$request = Request::create('https://other.com/', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
$cookies = $response->headers->getCookies();
|
||||
self::assertCount(1, $cookies);
|
||||
// domain is null when host does not match
|
||||
self::assertNull($cookies[0]->getDomain());
|
||||
// still uses auth cookie name since authBase is set
|
||||
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
|
||||
}
|
||||
|
||||
public function testCheckTokenWithEmptyReturnParameterFallsBackToPath(): void {
|
||||
$manager = $this->makeLoginManager();
|
||||
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
|
||||
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
// return parameter is present but empty string
|
||||
$request = Request::create('/?return=', 'GET');
|
||||
|
||||
$response = $manager->checkToken($payload, $request);
|
||||
|
||||
self::assertNotNull($response);
|
||||
$location = $response->headers->get('Location');
|
||||
self::assertNotNull($location);
|
||||
// should fall back to path since empty string is not a valid URL
|
||||
self::assertStringStartsWith('/', $location);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,4 +69,22 @@ final class GetTotpTraitTest extends TestCase {
|
||||
$this->expectException(\Throwable::class);
|
||||
$obj->publicGetTotp();
|
||||
}
|
||||
|
||||
public function testGetTotpThrowsHttpExceptionWhenNotTotpType(): void {
|
||||
// A HOTP URI loads successfully as an OTPInterface but is NOT a TOTPInterface,
|
||||
// so the instanceof check in getTotp() should throw an HttpException(500)
|
||||
$obj = $this->makeObject();
|
||||
$clock = $this->frozenClock();
|
||||
$utilities = $this->createUtilities($clock);
|
||||
$config = new ConfigBag(
|
||||
$utilities, $clock,
|
||||
3600, 'otpauth://hotp/Test-HOTP?secret=JBSWY3DPEHPK3PXP&counter=0', 0, false,
|
||||
'Error', 'Teapot', 'Too Many'
|
||||
);
|
||||
$obj->setConfig($config);
|
||||
|
||||
$this->expectException(HttpException::class);
|
||||
$this->expectExceptionMessage('Internal Server Exception');
|
||||
$obj->publicGetTotp();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,4 +104,73 @@ final class MakeNonceTraitTest extends TestCase {
|
||||
|
||||
$obj->publicMakeNonce();
|
||||
}
|
||||
|
||||
public function testMakeNonceRetriesAndSucceedsAfterCollision(): void {
|
||||
// Use a spy pool that returns isHit=true on the first getItem call
|
||||
// (simulating a collision), then delegates to a real ArrayAdapter for
|
||||
// subsequent calls so the retry succeeds.
|
||||
$realPool = new ArrayAdapter();
|
||||
$collisionCount = 0;
|
||||
|
||||
$spyPool = new class($realPool, $collisionCount) implements CacheItemPoolInterface {
|
||||
private int $hits = 0;
|
||||
public function __construct(
|
||||
private CacheItemPoolInterface $inner,
|
||||
private int &$hitCounter,
|
||||
) {}
|
||||
|
||||
public function getItem(string $key): CacheItemInterface {
|
||||
$item = $this->inner->getItem($key);
|
||||
// pretend the first requested key is already a hit (collision)
|
||||
if ($this->hits === 0) {
|
||||
$this->hits++;
|
||||
$this->hitCounter++;
|
||||
return new class($key) implements CacheItemInterface {
|
||||
public function __construct(private string $key) {}
|
||||
public function getKey(): string { return $this->key; }
|
||||
public function get(): mixed { return true; }
|
||||
public function isHit(): bool { return true; }
|
||||
public function set(mixed $value): static { return $this; }
|
||||
public function expiresAt(?\DateTimeInterface $expiration): static { return $this; }
|
||||
public function expiresAfter(int|\DateInterval|null $time): static { return $this; }
|
||||
};
|
||||
}
|
||||
return $item;
|
||||
}
|
||||
public function getItems(array $keys = []): iterable { return $this->inner->getItems($keys); }
|
||||
public function hasItem(string $key): bool { return $this->inner->hasItem($key); }
|
||||
public function clear(): bool { return $this->inner->clear(); }
|
||||
public function deleteItem(string $key): bool { return $this->inner->deleteItem($key); }
|
||||
public function deleteItems(array $keys): bool { return $this->inner->deleteItems($keys); }
|
||||
public function save(CacheItemInterface $item): bool { return $this->inner->save($item); }
|
||||
public function saveDeferred(CacheItemInterface $item): bool { return $this->inner->saveDeferred($item); }
|
||||
public function commit(): bool { return $this->inner->commit(); }
|
||||
};
|
||||
|
||||
$obj = $this->makeObject();
|
||||
$obj->setLogger(new NullLogger());
|
||||
$obj->setNonceCache($spyPool);
|
||||
|
||||
// should retry and succeed on the second attempt
|
||||
$nonce = $obj->publicMakeNonce();
|
||||
self::assertIsString($nonce);
|
||||
self::assertSame(20, strlen($nonce));
|
||||
self::assertSame(1, $collisionCount, 'Expected exactly one collision before success');
|
||||
}
|
||||
|
||||
public function testMakeNonceThrowsImmediatelyWithZeroRetries(): void {
|
||||
$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);
|
||||
$obj->publicMakeNonce(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,33 @@ final class StringTraitTest extends TestCase {
|
||||
public function testMakeCacheKeyEmptyString(): void {
|
||||
self::assertSame('', $this->makeCacheKey(''));
|
||||
}
|
||||
|
||||
public function testMakeCacheKeyWithOnlyInvalidChars(): void {
|
||||
// preg_replace with + collapses consecutive invalid chars into one _
|
||||
self::assertSame('_', $this->makeCacheKey('!!!'));
|
||||
self::assertSame('_', $this->makeCacheKey(' '));
|
||||
self::assertSame('_', $this->makeCacheKey('!@#'));
|
||||
self::assertSame('_', $this->makeCacheKey('!@ #'));
|
||||
}
|
||||
|
||||
public function testMakeCacheKeyTruncatesToExactly128(): void {
|
||||
$input = str_repeat('a', 128);
|
||||
self::assertSame(128, mb_strlen($this->makeCacheKey($input)));
|
||||
self::assertSame($input, $this->makeCacheKey($input));
|
||||
|
||||
$input129 = str_repeat('a', 129);
|
||||
self::assertSame(128, mb_strlen($this->makeCacheKey($input129)));
|
||||
}
|
||||
|
||||
public function testMakeCacheKeyWithMultibyteChars(): void {
|
||||
// multibyte chars are replaced with a single underscore
|
||||
$result = $this->makeCacheKey('héllo wörld');
|
||||
// é and ö are not in [A-Za-z0-9_.] so they become _
|
||||
self::assertSame('h_llo_w_rld', $result);
|
||||
}
|
||||
|
||||
public function testMakeCacheKeyWithEmoji(): void {
|
||||
$result = $this->makeCacheKey('a🎉b');
|
||||
self::assertSame('a_b', $result);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user