diff --git a/tests/Unit/Data/PayloadTest.php b/tests/Unit/Data/PayloadTest.php index 0647f49..b86fe30 100644 --- a/tests/Unit/Data/PayloadTest.php +++ b/tests/Unit/Data/PayloadTest.php @@ -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', diff --git a/tests/Unit/MonitorCacheKeysTest.php b/tests/Unit/MonitorCacheKeysTest.php index 8b2dfaa..32105e8 100644 --- a/tests/Unit/MonitorCacheKeysTest.php +++ b/tests/Unit/MonitorCacheKeysTest.php @@ -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()); + } } diff --git a/tests/Unit/PersistCacheTest.php b/tests/Unit/PersistCacheTest.php index eea8ba8..34bc45e 100644 --- a/tests/Unit/PersistCacheTest.php +++ b/tests/Unit/PersistCacheTest.php @@ -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()); + } } diff --git a/tests/Unit/Service/BackupCodeManagerTest.php b/tests/Unit/Service/BackupCodeManagerTest.php index 6cca98a..32fe766 100644 --- a/tests/Unit/Service/BackupCodeManagerTest.php +++ b/tests/Unit/Service/BackupCodeManagerTest.php @@ -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])); + } } diff --git a/tests/Unit/Service/DomainManagerTest.php b/tests/Unit/Service/DomainManagerTest.php index 449098d..4deca4a 100644 --- a/tests/Unit/Service/DomainManagerTest.php +++ b/tests/Unit/Service/DomainManagerTest.php @@ -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')); + } } diff --git a/tests/Unit/Service/LoginManagerTest.php b/tests/Unit/Service/LoginManagerTest.php index f87ff08..291cd39 100644 --- a/tests/Unit/Service/LoginManagerTest.php +++ b/tests/Unit/Service/LoginManagerTest.php @@ -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); + } } diff --git a/tests/Unit/Trait/GetTotpTraitTest.php b/tests/Unit/Trait/GetTotpTraitTest.php index 278bf10..03d0937 100644 --- a/tests/Unit/Trait/GetTotpTraitTest.php +++ b/tests/Unit/Trait/GetTotpTraitTest.php @@ -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(); + } } diff --git a/tests/Unit/Trait/MakeNonceTraitTest.php b/tests/Unit/Trait/MakeNonceTraitTest.php index ff704ee..f0fcd11 100644 --- a/tests/Unit/Trait/MakeNonceTraitTest.php +++ b/tests/Unit/Trait/MakeNonceTraitTest.php @@ -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); + } } diff --git a/tests/Unit/Trait/StringTraitTest.php b/tests/Unit/Trait/StringTraitTest.php index 68b32be..03c5bb7 100644 --- a/tests/Unit/Trait/StringTraitTest.php +++ b/tests/Unit/Trait/StringTraitTest.php @@ -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); + } }