From d099fc91e30a3ff0034b77549499da3cc3c53e2b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 10:58:04 +0000 Subject: [PATCH 01/10] Add CLAUDE.md with codebase guidance for Claude Code https://claude.ai/code/session_01MPJZkhQFasED3MivpLRVdg --- CLAUDE.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..34faacb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,51 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is `xibosignage/support`, a PHP utility library that provides reusable support classes for the Xibo Digital Signage Platform. It covers input sanitization, database abstraction, CSRF/nonce management, logging integrations, and exception handling with HTTP-aware responses. + +## Common Commands + +```bash +# Install dependencies +composer install + +# Run code style checks (PSR-2 based, with Xibo customisations) +vendor/bin/phpcs --standard=src/Standards/xibo_ruleset.xml src/ +``` + +There is no automated test suite in this repository — correctness is validated by the consuming applications. + +## Architecture + +### Module Breakdown (`src/Xibo/Support/`) + +**Database/** +`PdoStorageService` implements `StorageServiceInterface` providing a PDO/MySQL wrapper with named connection pooling, transaction management, and deadlock retry logic (max 2 retries via `DeadLockException`). + +**Exception/** +Seventeen custom exceptions all extend `GeneralException`. Each maps to an HTTP status (401, 403, 404, etc.) and can render a JSON HTTP response via `generateHttpResponse(ResponseInterface $response)`. This is the primary error-propagation mechanism across the Xibo platform. + +**Nonce/** +CSRF protection via bcrypt-hashed nonces stored through `StorageServiceInterface`. `CsrfMiddleware` is a PSR-7 middleware that validates nonces on incoming requests. `Nonce` entities support expiration, metadata, and JSON serialisation. + +**Sanitizer/** +`RespectSanitizer` wraps Respect\Validation and Symfony HtmlSanitizer to provide typed, validated input access (`getInt`, `getString`, `getDate`, `getHtml`, etc.). Callers pass an associative array of raw input; the sanitizer enforces types and optionally throws on validation failure. + +**Validator/** +Thin wrapper (`RespectValidator`) around Respect\Validation for standalone rule checks separate from sanitization. + +**Monolog/** +Two Monolog integrations: `RocketChatHandler` (webhook log posting) and `ProxyIpProcessor` (real-IP extraction from proxy headers). + +### Coding Standards + +The custom PHPCS ruleset at `src/Standards/xibo_ruleset.xml` extends PSR-2 with: +- Double-quoted strings enforced (Squiz rule) +- Forbidden functions: `delete`, `print`, `create_function` +- Line length checks ignore comments +- `ElseIfDeclaration` rule excluded + +PHP platform target is **8.1+**. From 6e7558388044298dcfa528996436e23ad7863c35 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 13:40:40 +0000 Subject: [PATCH 02/10] Add comprehensive PHPUnit test suite across all six modules Adds ~230 tests covering Sanitizer, Validator, Nonce, Database, Exception, and Monolog. Includes phpunit.xml.dist, dev dependencies, and autoload-dev config. Two known NonceService bugs are pinned with @todo tests and companion skipped tests documenting the intended behaviour. https://claude.ai/code/session_01MPJZkhQFasED3MivpLRVdg --- .gitignore | 2 + composer.json | 15 +- phpunit.xml.dist | 30 + tests/Database/PdoStorageServiceMockTest.php | 309 +++++++++ .../Database/PdoStorageServiceSqliteTest.php | 216 ++++++ tests/Exception/GeneralExceptionTest.php | 51 ++ tests/Exception/HttpStatusMappingTest.php | 180 +++++ tests/Fixtures/ConcreteNonceService.php | 42 ++ tests/Fixtures/CustomThrowException.php | 7 + tests/Monolog/ProxyIpProcessorTest.php | 81 +++ tests/Monolog/RocketChatHandlerTest.php | 123 ++++ tests/Nonce/CsrfMiddlewareTest.php | 208 ++++++ tests/Nonce/NonceServiceTest.php | 171 +++++ tests/Nonce/NonceTest.php | 140 ++++ tests/Sanitizer/RespectSanitizerTest.php | 636 ++++++++++++++++++ tests/Validator/RespectValidatorTest.php | 139 ++++ 16 files changed, 2349 insertions(+), 1 deletion(-) create mode 100644 phpunit.xml.dist create mode 100644 tests/Database/PdoStorageServiceMockTest.php create mode 100644 tests/Database/PdoStorageServiceSqliteTest.php create mode 100644 tests/Exception/GeneralExceptionTest.php create mode 100644 tests/Exception/HttpStatusMappingTest.php create mode 100644 tests/Fixtures/ConcreteNonceService.php create mode 100644 tests/Fixtures/CustomThrowException.php create mode 100644 tests/Monolog/ProxyIpProcessorTest.php create mode 100644 tests/Monolog/RocketChatHandlerTest.php create mode 100644 tests/Nonce/CsrfMiddlewareTest.php create mode 100644 tests/Nonce/NonceServiceTest.php create mode 100644 tests/Nonce/NonceTest.php create mode 100644 tests/Sanitizer/RespectSanitizerTest.php create mode 100644 tests/Validator/RespectValidatorTest.php diff --git a/.gitignore b/.gitignore index 3ce5adb..6e0fa71 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .idea vendor +.phpunit.cache +clover.xml diff --git a/composer.json b/composer.json index 1f8024d..116de74 100644 --- a/composer.json +++ b/composer.json @@ -24,10 +24,23 @@ "autoload": { "psr-4": { "Xibo\\" : "src/Xibo" } }, + "autoload-dev": { + "psr-4": { "Xibo\\Support\\Tests\\": "tests/" } + }, "require-dev": { "respect/validation": "2.2.*", "squizlabs/php_codesniffer": "3.*", - "exussum12/coverage-checker": "^0.11.2" + "exussum12/coverage-checker": "^0.11.2", + "phpunit/phpunit": "^10.5", + "monolog/monolog": "^2.9", + "guzzlehttp/guzzle": "^7.8", + "guzzlehttp/psr7": "^2.6", + "nesbot/carbon": "^2.72" + }, + "scripts": { + "lint": "phpcs --standard=src/Standards/xibo_ruleset.xml src/", + "test": "phpunit", + "test:coverage": "phpunit --coverage-clover clover.xml --coverage-text" }, "config": { "platform": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..e1390be --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + tests + + + + + src + + + + + + + + + + + + + diff --git a/tests/Database/PdoStorageServiceMockTest.php b/tests/Database/PdoStorageServiceMockTest.php new file mode 100644 index 0000000..d9136ab --- /dev/null +++ b/tests/Database/PdoStorageServiceMockTest.php @@ -0,0 +1,309 @@ + */ + public array $injectedConnections = []; + + public function connect($host, $user, $pass, $name = null): \PDO + { + // Pop the next injected connection for any name requested + if (!empty($this->injectedConnections)) { + return array_shift($this->injectedConnections); + } + throw new \RuntimeException('No injected connection available'); + } +} + +class PdoStorageServiceMockTest extends TestCase +{ + private function config(): array + { + return ['host' => 'localhost', 'user' => 'root', 'pass' => '', 'name' => 'test']; + } + + private function service(?\Psr\Log\LoggerInterface $logger = null): InjectablePdoStorageService + { + return new InjectablePdoStorageService($logger ?? new NullLogger(), $this->config()); + } + + private function pdoException(int $code): \PDOException + { + $e = new \PDOException('Database error', $code); + $e->errorInfo = ['HY000', $code, 'Database error']; + return $e; + } + + private function mockPdoThatSucceeds(mixed $fetchResult = null): \PDO + { + $stmt = $this->createMock(\PDOStatement::class); + $stmt->method('execute')->willReturn(true); + $stmt->method('fetch')->willReturn($fetchResult ?? ['id' => 1]); + $stmt->method('fetchAll')->willReturn($fetchResult !== null ? [$fetchResult] : [['id' => 1]]); + $stmt->method('rowCount')->willReturn(1); + + $pdo = $this->createMock(\PDO::class); + $pdo->method('prepare')->willReturn($stmt); + $pdo->method('inTransaction')->willReturn(false); + $pdo->method('beginTransaction')->willReturn(true); + $pdo->method('lastInsertId')->willReturn('42'); + return $pdo; + } + + private function mockPdoThatThrowsOnExecute(\PDOException $exception): \PDO + { + $stmt = $this->createMock(\PDOStatement::class); + $stmt->method('execute')->willThrowException($exception); + + $pdo = $this->createMock(\PDO::class); + $pdo->method('prepare')->willReturn($stmt); + $pdo->method('inTransaction')->willReturn(false); + $pdo->method('beginTransaction')->willReturn(true); + return $pdo; + } + + // ------------------------------------------------------------------ + // exists — reconnect on 2006 + // ------------------------------------------------------------------ + + public function testExistsRethrowsNon2006Exception(): void + { + $svc = $this->service(); + $svc->injectedConnections[] = $this->mockPdoThatThrowsOnExecute($this->pdoException(1045)); + + $this->expectException(\PDOException::class); + $svc->exists('SELECT 1', []); + } + + public function testExistsRethrowsWhenReconnectFalseAndExceptionIsAny(): void + { + $svc = $this->service(); + $svc->injectedConnections[] = $this->mockPdoThatThrowsOnExecute($this->pdoException(2006)); + + $this->expectException(\PDOException::class); + // reconnect=false → should rethrow immediately + $svc->exists('SELECT 1', [], 'default', false); + } + + public function testExistsReconnectsOn2006AndRetries(): void + { + $svc = $this->service(); + // First connection throws 2006, second succeeds + $svc->injectedConnections[] = $this->mockPdoThatThrowsOnExecute($this->pdoException(2006)); + $svc->injectedConnections[] = $this->mockPdoThatSucceeds(); + + $result = $svc->exists('SELECT 1', [], 'default', true); + $this->assertTrue($result); + } + + // ------------------------------------------------------------------ + // insert — reconnect on 2006 + // ------------------------------------------------------------------ + + public function testInsertReconnectsOn2006(): void + { + $svc = $this->service(); + $svc->injectedConnections[] = $this->mockPdoThatThrowsOnExecute($this->pdoException(2006)); + $svc->injectedConnections[] = $this->mockPdoThatSucceeds(); + + $id = $svc->insert('INSERT INTO t (x) VALUES (:x)', [':x' => 1], 'default', true); + $this->assertSame(42, $id); + } + + // ------------------------------------------------------------------ + // update — reconnect on 2006 + // ------------------------------------------------------------------ + + public function testUpdateReconnectsOn2006(): void + { + $svc = $this->service(); + $svc->injectedConnections[] = $this->mockPdoThatThrowsOnExecute($this->pdoException(2006)); + $svc->injectedConnections[] = $this->mockPdoThatSucceeds(); + + $rows = $svc->update('UPDATE t SET x = :x', [':x' => 1], 'default', true); + $this->assertSame(1, $rows); + } + + // ------------------------------------------------------------------ + // select — reconnect on 2006 + // ------------------------------------------------------------------ + + public function testSelectReconnectsOn2006(): void + { + $svc = $this->service(); + $svc->injectedConnections[] = $this->mockPdoThatThrowsOnExecute($this->pdoException(2006)); + $svc->injectedConnections[] = $this->mockPdoThatSucceeds(); + + $rows = $svc->select('SELECT * FROM t', [], 'default', true); + $this->assertIsArray($rows); + } + + // ------------------------------------------------------------------ + // isolated — reconnect on 2006 + // ------------------------------------------------------------------ + + public function testIsolatedReconnectsOn2006(): void + { + $svc = $this->service(); + $svc->injectedConnections[] = $this->mockPdoThatThrowsOnExecute($this->pdoException(2006)); + $svc->injectedConnections[] = $this->mockPdoThatSucceeds(); + + $svc->isolated('INSERT INTO t (x) VALUES (:x)', [':x' => 1], 'isolated', true); + $this->assertTrue(true); // no exception = success + } + + // ------------------------------------------------------------------ + // updateWithDeadlockLoop + // ------------------------------------------------------------------ + + public function testDeadlockLoopSucceedsOnFirstTry(): void + { + $logger = $this->createMock(\Psr\Log\LoggerInterface::class); + $logger->expects($this->never())->method('debug'); // no retry message + + $svc = $this->service($logger); + $svc->injectedConnections[] = $this->mockPdoThatSucceeds(); + + $svc->updateWithDeadlockLoop('UPDATE t SET x = 1', []); + $this->assertTrue(true); + } + + public function testDeadlockLoopRetriesOn1213ThenSucceeds(): void + { + $stmt = $this->createMock(\PDOStatement::class); + $stmt->expects($this->exactly(2)) + ->method('execute') + ->willReturnOnConsecutiveCalls( + $this->throwException($this->pdoException(1213)), + true + ); + + $pdo = $this->createMock(\PDO::class); + $pdo->method('prepare')->willReturn($stmt); + + $svc = $this->service(); + $svc->injectedConnections[] = $pdo; + + $svc->updateWithDeadlockLoop('UPDATE t SET x = 1', []); + $this->assertTrue(true); + } + + public function testDeadlockLoopRetriesOn1205ThenSucceeds(): void + { + $stmt = $this->createMock(\PDOStatement::class); + $stmt->expects($this->exactly(2)) + ->method('execute') + ->willReturnOnConsecutiveCalls( + $this->throwException($this->pdoException(1205)), + true + ); + + $pdo = $this->createMock(\PDO::class); + $pdo->method('prepare')->willReturn($stmt); + + $svc = $this->service(); + $svc->injectedConnections[] = $pdo; + + $svc->updateWithDeadlockLoop('UPDATE t SET x = 1', []); + $this->assertTrue(true); + } + + public function testDeadlockLoopRethrowsNon1213Or1205Immediately(): void + { + $svc = $this->service(); + $svc->injectedConnections[] = $this->mockPdoThatThrowsOnExecute($this->pdoException(1045)); + + $this->expectException(\PDOException::class); + $svc->updateWithDeadlockLoop('UPDATE t SET x = 1', []); + } + + public function testDeadlockLoopThrowsDeadLockExceptionAfterMaxRetries(): void + { + // Fails all 3 attempts (initial + 2 retries) + $stmt = $this->createMock(\PDOStatement::class); + $stmt->method('execute')->willThrowException($this->pdoException(1213)); + + $pdo = $this->createMock(\PDO::class); + $pdo->method('prepare')->willReturn($stmt); + + $svc = $this->service(); + $svc->injectedConnections[] = $pdo; + + $this->expectException(DeadLockException::class); + $this->expectExceptionMessageMatches('/after 2 retries/'); + $svc->updateWithDeadlockLoop('UPDATE t SET x = 1', []); + } + + // ------------------------------------------------------------------ + // getVersion + // ------------------------------------------------------------------ + + public function testGetVersionParsesVersionString(): void + { + $stmt = $this->createMock(\PDOStatement::class); + $stmt->method('execute')->willReturn(true); + $stmt->method('fetchAll')->willReturn([['v' => '8.0.32-MariaDB']]); + + $pdo = $this->createMock(\PDO::class); + $pdo->method('prepare')->willReturn($stmt); + + $svc = $this->service(); + $svc->injectedConnections[] = $pdo; + + // Reset static version cache via reflection + $ref = new \ReflectionClass(PdoStorageService::class); + $prop = $ref->getProperty('_version'); + $prop->setAccessible(true); + $prop->setValue(null, null); + + $version = $svc->getVersion(); + $this->assertSame('8.0.32', $version); + } + + public function testGetVersionReturnsNullWhenNoRows(): void + { + $stmt = $this->createMock(\PDOStatement::class); + $stmt->method('execute')->willReturn(true); + $stmt->method('fetchAll')->willReturn([]); + + $pdo = $this->createMock(\PDO::class); + $pdo->method('prepare')->willReturn($stmt); + + $svc = $this->service(); + $svc->injectedConnections[] = $pdo; + + $ref = new \ReflectionClass(PdoStorageService::class); + $prop = $ref->getProperty('_version'); + $prop->setAccessible(true); + $prop->setValue(null, null); + + $this->assertNull($svc->getVersion()); + } + + // ------------------------------------------------------------------ + // setTimeZone + // ------------------------------------------------------------------ + + public function testSetTimeZoneExecutesSetQuery(): void + { + $pdo = $this->createMock(\PDO::class); + $pdo->expects($this->once()) + ->method('query') + ->with("SET time_zone = 'UTC';"); + + $svc = $this->service(); + $svc->injectedConnections[] = $pdo; + + $svc->setTimeZone('UTC'); + } +} diff --git a/tests/Database/PdoStorageServiceSqliteTest.php b/tests/Database/PdoStorageServiceSqliteTest.php new file mode 100644 index 0000000..bcd20f0 --- /dev/null +++ b/tests/Database/PdoStorageServiceSqliteTest.php @@ -0,0 +1,216 @@ +setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + return $pdo; + } +} + +class PdoStorageServiceSqliteTest extends TestCase +{ + private SqlitePdoStorageService $service; + + protected function setUp(): void + { + $this->service = new SqlitePdoStorageService( + new NullLogger(), + ['host' => 'localhost', 'user' => 'root', 'pass' => '', 'name' => 'test'] + ); + + // Bootstrap a simple table + $this->service->getConnection('default')->exec( + 'CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)' + ); + // Isolated connection gets its own in-memory DB + $this->service->getConnection('isolated')->exec( + 'CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)' + ); + } + + protected function tearDown(): void + { + $this->service->close(); + } + + // ------------------------------------------------------------------ + // Connection management + // ------------------------------------------------------------------ + + public function testGetConnectionLazilyOpensOnFirstUse(): void + { + $conn = $this->service->getConnection('default'); + $this->assertInstanceOf(\PDO::class, $conn); + } + + public function testGetConnectionReturnsSameInstanceOnSubsequentCalls(): void + { + $conn1 = $this->service->getConnection('default'); + $conn2 = $this->service->getConnection('default'); + $this->assertSame($conn1, $conn2); + } + + public function testSetConnectionStoresUnderNamedKey(): void + { + $this->service->setConnection('secondary'); + $conn = $this->service->getConnection('secondary'); + $this->assertInstanceOf(\PDO::class, $conn); + } + + public function testCloseSpecificConnectionRemovesIt(): void + { + $this->service->getConnection('default'); + $this->service->close('default'); + // After close, a new connection is lazily created — it should still work + $newConn = $this->service->getConnection('default'); + $this->assertInstanceOf(\PDO::class, $newConn); + } + + public function testCloseAllConnectionsClearsPool(): void + { + $this->service->getConnection('default'); + $this->service->getConnection('isolated'); + $this->service->close(); // close all + // New connections created on next access + $this->assertInstanceOf(\PDO::class, $this->service->getConnection('default')); + } + + // ------------------------------------------------------------------ + // insert / select / update / exists + // ------------------------------------------------------------------ + + public function testInsertReturnsLastInsertId(): void + { + $this->service->insert("INSERT INTO items (name) VALUES (:name)", [':name' => 'Alice']); + $this->service->commitIfNecessary('default'); + + // SQLite AUTOINCREMENT starts at 1 + $rows = $this->service->select("SELECT id, name FROM items WHERE name = :name", [':name' => 'Alice']); + $this->assertCount(1, $rows); + $this->assertSame('Alice', $rows[0]['name']); + } + + public function testInsertBeginsTransactionWhenNoneActive(): void + { + $this->service->insert("INSERT INTO items (name) VALUES (:name)", [':name' => 'Bob']); + $conn = $this->service->getConnection('default'); + // Transaction should be active (not yet committed) + $this->assertTrue($conn->inTransaction()); + $conn->rollBack(); + } + + public function testUpdateReturnsAffectedRowCount(): void + { + $this->service->insert("INSERT INTO items (name) VALUES (:name)", [':name' => 'Carol']); + $this->service->commitIfNecessary('default'); + + $count = $this->service->update( + "UPDATE items SET name = :newname WHERE name = :oldname", + [':newname' => 'Caroline', ':oldname' => 'Carol'] + ); + $this->service->commitIfNecessary('default'); + $this->assertSame(1, $count); + } + + public function testSelectReturnsAssocArray(): void + { + $this->service->insert("INSERT INTO items (name) VALUES (:name)", [':name' => 'Dave']); + $this->service->commitIfNecessary('default'); + + $rows = $this->service->select("SELECT name FROM items WHERE name = :name", [':name' => 'Dave']); + $this->assertIsArray($rows); + $this->assertCount(1, $rows); + $this->assertArrayHasKey('name', $rows[0]); + $this->assertSame('Dave', $rows[0]['name']); + } + + public function testSelectReturnsEmptyArrayWhenNoRows(): void + { + $rows = $this->service->select("SELECT * FROM items WHERE name = :name", [':name' => 'Nobody']); + $this->assertSame([], $rows); + } + + public function testExistsReturnsTrueWhenRowPresent(): void + { + $this->service->insert("INSERT INTO items (name) VALUES (:name)", [':name' => 'Eve']); + $this->service->commitIfNecessary('default'); + + $result = $this->service->exists("SELECT 1 FROM items WHERE name = :name", [':name' => 'Eve']); + $this->assertTrue($result); + } + + public function testExistsReturnsFalseWhenNoRows(): void + { + $result = $this->service->exists("SELECT 1 FROM items WHERE name = :name", [':name' => 'Ghost']); + $this->assertFalse($result); + } + + public function testCommitIfNecessaryCommitsActiveTransaction(): void + { + $this->service->insert("INSERT INTO items (name) VALUES (:name)", [':name' => 'Frank']); + $this->service->commitIfNecessary('default'); + + $conn = $this->service->getConnection('default'); + $this->assertFalse($conn->inTransaction()); + + // Data should be visible after commit + $rows = $this->service->select("SELECT name FROM items WHERE name = 'Frank'", []); + $this->assertCount(1, $rows); + } + + public function testCommitIfNecessaryIsNoopWhenNoTransaction(): void + { + // No transaction started — commitIfNecessary should not throw + $this->service->commitIfNecessary('default'); + $this->assertTrue(true); // just verifying no exception thrown + } + + public function testIsolatedExecutesOnIsolatedConnection(): void + { + $this->service->isolated( + "INSERT INTO items (name) VALUES (:name)", + [':name' => 'IsolatedRow'] + ); + $rows = $this->service->select( + "SELECT name FROM items WHERE name = :name", + [':name' => 'IsolatedRow'], + 'isolated' + ); + $this->assertCount(1, $rows); + } + + public function testStatsIncrementForSelectOperation(): void + { + $before = $this->service->stats()['default']['select'] ?? 0; + $this->service->select("SELECT 1", []); + $after = $this->service->stats()['default']['select'] ?? 0; + $this->assertSame($before + 1, $after); + } + + public function testLogSqlCallsLoggerDebug(): void + { + $logger = $this->createMock(\Psr\Log\LoggerInterface::class); + $logger->expects($this->atLeastOnce())->method('debug'); + + $service = new SqlitePdoStorageService( + $logger, + ['host' => 'localhost', 'user' => 'root', 'pass' => '', 'name' => 'test'] + ); + $service->getConnection('default')->exec( + 'CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)' + ); + $service->select("SELECT 1", []); + } +} diff --git a/tests/Exception/GeneralExceptionTest.php b/tests/Exception/GeneralExceptionTest.php new file mode 100644 index 0000000..c1221c5 --- /dev/null +++ b/tests/Exception/GeneralExceptionTest.php @@ -0,0 +1,51 @@ +assertSame(500, $e->getHttpStatusCode()); + } + + public function testGenerateHttpResponseWritesJsonBody(): void + { + $e = new GeneralException('something broke', 99); + $response = $e->generateHttpResponse(new Response()); + + $body = json_decode((string) $response->getBody(), true); + $this->assertSame(99, $body['error']); + $this->assertSame('something broke', $body['message']); + } + + public function testGenerateHttpResponseAddsContentTypeHeader(): void + { + $e = new GeneralException('err'); + $response = $e->generateHttpResponse(new Response()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + } + + public function testGenerateHttpResponseSetsStatusCode(): void + { + $e = new GeneralException('err'); + $response = $e->generateHttpResponse(new Response()); + $this->assertSame(500, $response->getStatusCode()); + } + + public function testGetErrorDataReturnsEmptyArrayByDefault(): void + { + $e = new GeneralException('err'); + $response = $e->generateHttpResponse(new Response()); + $body = json_decode((string) $response->getBody(), true); + // Only 'error' and 'message' keys — no extra keys from getErrorData + $this->assertArrayHasKey('error', $body); + $this->assertArrayHasKey('message', $body); + $this->assertCount(2, $body); + } +} diff --git a/tests/Exception/HttpStatusMappingTest.php b/tests/Exception/HttpStatusMappingTest.php new file mode 100644 index 0000000..a5068f4 --- /dev/null +++ b/tests/Exception/HttpStatusMappingTest.php @@ -0,0 +1,180 @@ +assertSame($expected, $exception->getHttpStatusCode()); + } + + /** @dataProvider statusCodeProvider */ + public function testGenerateHttpResponseSetsCorrectStatus(GeneralException $exception, int $expected): void + { + $response = $exception->generateHttpResponse(new Response()); + $this->assertSame($expected, $response->getStatusCode()); + } + + public static function statusCodeProvider(): array + { + return [ + 'GeneralException' => [new GeneralException('e'), 500], + 'ConfigurationException' => [new ConfigurationException('e'), 500], + 'ControllerNotImplemented' => [new ControllerNotImplemented('e'), 500], + 'DeadLockException' => [new DeadLockException('e'), 500], + 'ExpiredException' => [new ExpiredException('e'), 500], + 'InstallationError' => [new InstallationError('e'), 500], + 'InvalidNonceException' => [new InvalidNonceException(), 500], + 'LibraryFullException' => [new LibraryFullException('e'), 500], + 'TaskRunException' => [new TaskRunException('e'), 500], + 'ValueTooLargeException' => [new ValueTooLargeException('e'), 500], + 'AuthenticationRequiredException' => [new AuthenticationRequiredException('e'), 401], + 'AccessDeniedException' => [new AccessDeniedException('e'), 403], + 'InstanceSuspendedException' => [new InstanceSuspendedException(), 403], + 'UpgradePendingException' => [new UpgradePendingException(), 403], + 'NotFoundException' => [new NotFoundException(), 404], + 'DuplicateEntityException' => [new DuplicateEntityException('e'), 409], + 'InvalidArgumentException' => [new InvalidArgumentException('e'), 422], + ]; + } + + // ------------------------------------------------------------------ + // Default messages + // ------------------------------------------------------------------ + + public function testNotFoundExceptionDefaultMessage(): void + { + $this->assertSame('Not Found', (new NotFoundException())->getMessage()); + } + + public function testUpgradePendingExceptionDefaultMessage(): void + { + $this->assertSame('Upgrade Pending', (new UpgradePendingException())->getMessage()); + } + + public function testInstanceSuspendedExceptionDefaultMessage(): void + { + $this->assertSame('Instance Suspended', (new InstanceSuspendedException())->getMessage()); + } + + public function testInvalidNonceExceptionDefaultMessage(): void + { + $this->assertSame('Token Expired', (new InvalidNonceException())->getMessage()); + } + + public function testInvalidNonceExceptionPreservesPrevious(): void + { + $prev = new \RuntimeException('root cause'); + $e = new InvalidNonceException('msg', 0, $prev); + $this->assertSame($prev, $e->getPrevious()); + } + + // ------------------------------------------------------------------ + // InvalidArgumentException message synthesis + // ------------------------------------------------------------------ + + public function testInvalidArgumentSynthesizesMessageFromProperty(): void + { + $e = new InvalidArgumentException('', 'username'); + $this->assertSame('Invalid Argument username', $e->getMessage()); + } + + public function testInvalidArgumentEmptyMessageAndNoProperty(): void + { + $e = new InvalidArgumentException(); + $this->assertSame('Invalid Argument', $e->getMessage()); + } + + public function testInvalidArgumentCustomMessagePreserved(): void + { + $e = new InvalidArgumentException('Custom error', 'field'); + $this->assertSame('Custom error', $e->getMessage()); + } + + // ------------------------------------------------------------------ + // Error data payloads + // ------------------------------------------------------------------ + + public function testAccessDeniedExceptionIncludesHelpInPayload(): void + { + $e = new AccessDeniedException('denied', 'https://help.example.com'); + $body = $this->decodeResponse($e); + $this->assertSame('https://help.example.com', $body['help']); + } + + public function testInvalidArgumentExceptionIncludesPropertyAndHelp(): void + { + $e = new InvalidArgumentException('bad input', 'myField', 'https://docs.example.com'); + $body = $this->decodeResponse($e); + $this->assertSame('myField', $body['property']); + $this->assertSame('https://docs.example.com', $body['help']); + } + + public function testNotFoundExceptionIncludesPropertyAndHelp(): void + { + $e = new NotFoundException('Not found', 'itemId', 'https://docs.example.com'); + $body = $this->decodeResponse($e); + $this->assertSame('itemId', $body['property']); + $this->assertSame('https://docs.example.com', $body['help']); + } + + public function testDuplicateEntityExceptionIncludesPropertyAndHelp(): void + { + $e = new DuplicateEntityException('duplicate', 'email', 'use a different email'); + $body = $this->decodeResponse($e); + $this->assertSame('email', $body['property']); + $this->assertSame('use a different email', $body['help']); + } + + public function testUpgradePendingExceptionIncludesPropertyAndHelp(): void + { + $e = new UpgradePendingException('pending', 'version', 'please upgrade'); + $body = $this->decodeResponse($e); + $this->assertSame('version', $body['property']); + $this->assertSame('please upgrade', $body['help']); + } + + public function testInstanceSuspendedExceptionIncludesPropertyAndHelp(): void + { + $e = new InstanceSuspendedException('suspended', 'instanceId', 'contact support'); + $body = $this->decodeResponse($e); + $this->assertSame('instanceId', $body['property']); + $this->assertSame('contact support', $body['help']); + } + + public function testNullHelpAndPropertyAreIncludedAsNull(): void + { + $e = new InvalidArgumentException('err'); + $body = $this->decodeResponse($e); + $this->assertNull($body['property']); + $this->assertNull($body['help']); + } + + private function decodeResponse(GeneralException $e): array + { + $response = $e->generateHttpResponse(new Response()); + return json_decode((string) $response->getBody(), true); + } +} diff --git a/tests/Fixtures/ConcreteNonceService.php b/tests/Fixtures/ConcreteNonceService.php new file mode 100644 index 0000000..4baac41 --- /dev/null +++ b/tests/Fixtures/ConcreteNonceService.php @@ -0,0 +1,42 @@ +store, fn($n) => $n->lookup === $lookup); + if (count($matches) !== 1) { + throw new NotFoundException('Nonce not found'); + } + return array_values($matches)[0]; + } + + public function persist($nonce): void + { + $this->store[] = $nonce; + } + + public function remove($nonce): void + { + $this->store = array_filter( + $this->store, + fn($n) => $n->lookup !== $nonce->lookup + ); + } + + public function removeAllForEntity($entityId, $action): void + { + $this->store = array_filter( + $this->store, + fn($n) => !($n->entityId === $entityId && $n->action === $action) + ); + } +} diff --git a/tests/Fixtures/CustomThrowException.php b/tests/Fixtures/CustomThrowException.php new file mode 100644 index 0000000..f9c6a90 --- /dev/null +++ b/tests/Fixtures/CustomThrowException.php @@ -0,0 +1,7 @@ +originalServer = $_SERVER; + foreach (['X_FORWARDED_FOR', 'HTTP_X_FORWARDED_FOR', 'CLIENT_IP', 'REMOTE_ADDR'] as $key) { + unset($_SERVER[$key]); + } + } + + protected function tearDown(): void + { + $_SERVER = $this->originalServer; + } + + public function testGetIpReturnsNullWhenNoServerKeysPresent(): void + { + $this->assertNull(ProxyIpProcessor::getIp()); + } + + public function testGetIpReadsXForwardedForFirst(): void + { + $_SERVER['X_FORWARDED_FOR'] = '10.0.0.1'; + $_SERVER['HTTP_X_FORWARDED_FOR'] = '10.0.0.2'; + $_SERVER['CLIENT_IP'] = '10.0.0.3'; + $_SERVER['REMOTE_ADDR'] = '10.0.0.4'; + $this->assertSame('10.0.0.1', ProxyIpProcessor::getIp()); + } + + public function testGetIpFallsBackToHttpXForwardedFor(): void + { + $_SERVER['HTTP_X_FORWARDED_FOR'] = '10.0.0.2'; + $_SERVER['CLIENT_IP'] = '10.0.0.3'; + $_SERVER['REMOTE_ADDR'] = '10.0.0.4'; + $this->assertSame('10.0.0.2', ProxyIpProcessor::getIp()); + } + + public function testGetIpFallsBackToClientIp(): void + { + $_SERVER['CLIENT_IP'] = '10.0.0.3'; + $_SERVER['REMOTE_ADDR'] = '10.0.0.4'; + $this->assertSame('10.0.0.3', ProxyIpProcessor::getIp()); + } + + public function testGetIpFallsBackToRemoteAddr(): void + { + $_SERVER['REMOTE_ADDR'] = '192.168.1.1'; + $this->assertSame('192.168.1.1', ProxyIpProcessor::getIp()); + } + + public function testInvokeAttachesClientIpToExtra(): void + { + $_SERVER['REMOTE_ADDR'] = '1.2.3.4'; + $processor = new ProxyIpProcessor(); + $record = $processor(['extra' => [], 'message' => 'test']); + $this->assertSame('1.2.3.4', $record['extra']['clientIp']); + } + + public function testInvokeReturnsModifiedRecord(): void + { + $processor = new ProxyIpProcessor(); + $record = $processor(['extra' => ['existing' => 'value'], 'message' => 'test']); + $this->assertArrayHasKey('clientIp', $record['extra']); + $this->assertArrayHasKey('existing', $record['extra']); + } + + public function testGetIpIsStaticallyCallable(): void + { + $_SERVER['REMOTE_ADDR'] = '5.5.5.5'; + $this->assertSame('5.5.5.5', ProxyIpProcessor::getIp()); + } +} diff --git a/tests/Monolog/RocketChatHandlerTest.php b/tests/Monolog/RocketChatHandlerTest.php new file mode 100644 index 0000000..3cddce4 --- /dev/null +++ b/tests/Monolog/RocketChatHandlerTest.php @@ -0,0 +1,123 @@ +push(Middleware::history($container)); + $client = new Client(['handler' => $stack]); + + $handler = new RocketChatHandler('https://example.com/webhook', $client, $level); + return [$handler, &$container]; + } + + private function makeRecord(string $channel, int $level, string $levelName, string $message): array + { + return [ + 'channel' => $channel, + 'level' => $level, + 'level_name' => $levelName, + 'message' => $message, + 'context' => [], + 'extra' => [], + 'datetime' => new \DateTimeImmutable(), + 'formatted' => $message, + ]; + } + + public function testWritePostsToConfiguredUrl(): void + { + [$handler, $container] = $this->makeHandler(); + $handler->handle($this->makeRecord('app', Logger::CRITICAL, 'CRITICAL', 'Test')); + + $this->assertCount(1, $container); + $this->assertSame('POST', $container[0]['request']->getMethod()); + $this->assertSame('https://example.com/webhook', (string) $container[0]['request']->getUri()); + } + + public function testWriteSendsJsonPayloadWithFormattedText(): void + { + [$handler, $container] = $this->makeHandler(); + $handler->handle($this->makeRecord('app', Logger::CRITICAL, 'CRITICAL', 'Something went wrong')); + + $body = json_decode((string) $container[0]['request']->getBody(), true); + $this->assertArrayHasKey('text', $body); + $this->assertStringContainsString('*app*', $body['text']); + $this->assertStringContainsString('*CRITICAL*', $body['text']); + $this->assertStringContainsString('Something went wrong', $body['text']); + } + + public function testWriteSendsAttachmentsWithColor(): void + { + [$handler, $container] = $this->makeHandler(); + $handler->handle($this->makeRecord('app', Logger::CRITICAL, 'CRITICAL', 'Test')); + + $body = json_decode((string) $container[0]['request']->getBody(), true); + $this->assertArrayHasKey('attachments', $body); + $this->assertArrayHasKey('color', $body['attachments']); + } + + /** @dataProvider colorProvider */ + public function testAlertColors(int $level, string $levelName, string $expectedColor): void + { + // Add enough mock responses + $container = []; + $mock = new MockHandler(array_fill(0, 10, new Response(200))); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($container)); + $client = new Client(['handler' => $stack]); + + $handler = new RocketChatHandler('https://example.com/webhook', $client, Logger::DEBUG); + $handler->handle($this->makeRecord('test', $level, $levelName, 'msg')); + + $body = json_decode((string) $container[0]['request']->getBody(), true); + $this->assertSame($expectedColor, $body['attachments']['color']); + } + + public static function colorProvider(): array + { + return [ + 'EMERGENCY' => [Logger::EMERGENCY, 'EMERGENCY', '#f44b42'], + 'ALERT' => [Logger::ALERT, 'ALERT', '#f44b42'], + 'CRITICAL' => [Logger::CRITICAL, 'CRITICAL', '#f44b42'], + 'ERROR' => [Logger::ERROR, 'ERROR', '#f44b42'], + 'WARNING' => [Logger::WARNING, 'WARNING', '#f4eb42'], + 'INFO' => [Logger::INFO, 'INFO', '#42f44e'], + 'DEBUG' => [Logger::DEBUG, 'DEBUG', '#848484'], + 'NOTICE' => [Logger::NOTICE, 'NOTICE', '#3c55e0'], + ]; + } + + public function testDefaultLevelIsCritical(): void + { + $container = []; + $mock = new MockHandler([new Response(200), new Response(200)]); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($container)); + $client = new Client(['handler' => $stack]); + + $handler = new RocketChatHandler('https://example.com/webhook', $client); + + // ERROR should be handled (>= CRITICAL? No, ERROR < CRITICAL) + // Only CRITICAL and above pass default threshold + $handler->handle($this->makeRecord('app', Logger::ERROR, 'ERROR', 'err')); + $this->assertCount(0, $container); // ERROR < CRITICAL, not sent + + $handler->handle($this->makeRecord('app', Logger::CRITICAL, 'CRITICAL', 'crit')); + $this->assertCount(1, $container); // CRITICAL sent + } +} diff --git a/tests/Nonce/CsrfMiddlewareTest.php b/tests/Nonce/CsrfMiddlewareTest.php new file mode 100644 index 0000000..cae92ff --- /dev/null +++ b/tests/Nonce/CsrfMiddlewareTest.php @@ -0,0 +1,208 @@ +originalSession = $_SESSION ?? null; + $_SESSION = []; + } + + protected function tearDown(): void + { + if ($this->originalSession === null) { + unset($_SESSION); + } else { + $_SESSION = $this->originalSession; + } + } + + private function next(): callable + { + return fn($request, $response) => $response; + } + + private function middleware(string $key = 'csrfToken'): CsrfMiddleware + { + return new CsrfMiddleware($key); + } + + // ------------------------------------------------------------------ + // Constructor validation + // ------------------------------------------------------------------ + + public function testConstructorRejectsEmptyKey(): void + { + $this->expectException(\OutOfBoundsException::class); + new CsrfMiddleware(''); + } + + public function testConstructorRejectsKeyWithSpaces(): void + { + $this->expectException(\OutOfBoundsException::class); + new CsrfMiddleware('foo bar'); + } + + public function testConstructorRejectsKeyWithSpecialChars(): void + { + $this->expectException(\OutOfBoundsException::class); + new CsrfMiddleware('foo!'); + } + + public function testConstructorAcceptsAlphanumericKey(): void + { + $mw = new CsrfMiddleware('csrfToken'); + $this->assertInstanceOf(CsrfMiddleware::class, $mw); + } + + public function testConstructorAcceptsKeyWithDashAndUnderscore(): void + { + $mw = new CsrfMiddleware('csrf-token_v2'); + $this->assertInstanceOf(CsrfMiddleware::class, $mw); + } + + // ------------------------------------------------------------------ + // Session token management + // ------------------------------------------------------------------ + + public function testGeneratesSessionTokenWhenAbsent(): void + { + $mw = $this->middleware(); + $request = new ServerRequest('GET', '/'); + $mw($request, new Response(), $this->next()); + $this->assertArrayHasKey('csrfToken', $_SESSION); + $this->assertSame(40, strlen($_SESSION['csrfToken'])); // bin2hex(20 bytes) + } + + public function testReusesExistingSessionToken(): void + { + $_SESSION['csrfToken'] = 'existing-token-value'; + $mw = $this->middleware(); + $request = new ServerRequest('GET', '/'); + $mw($request, new Response(), $this->next()); + $this->assertSame('existing-token-value', $_SESSION['csrfToken']); + } + + // ------------------------------------------------------------------ + // Safe methods pass through without CSRF check + // ------------------------------------------------------------------ + + /** @dataProvider safeMethods */ + public function testSafeMethodsPassThrough(string $method): void + { + $mw = $this->middleware(); + $request = new ServerRequest($method, '/'); + $response = $mw($request, new Response(), $this->next()); + $this->assertInstanceOf(Response::class, $response); + } + + public static function safeMethods(): array + { + return [['GET'], ['HEAD'], ['OPTIONS']]; + } + + // ------------------------------------------------------------------ + // POST/PUT/DELETE require valid CSRF token + // ------------------------------------------------------------------ + + /** @dataProvider unsafeMethods */ + public function testUnsafeMethodsThrowWithoutToken(string $method): void + { + $this->expectException(InvalidNonceException::class); + $_SESSION['csrfToken'] = 'expected-token'; + $mw = $this->middleware(); + $request = new ServerRequest($method, '/'); + $mw($request, new Response(), $this->next()); + } + + /** @dataProvider unsafeMethods */ + public function testUnsafeMethodsPassWithMatchingHeader(string $method): void + { + $_SESSION['csrfToken'] = 'abc123'; + $mw = $this->middleware(); + $request = (new ServerRequest($method, '/')) + ->withHeader('X-XSRF-TOKEN', 'abc123'); + $response = $mw($request, new Response(), $this->next()); + $this->assertInstanceOf(Response::class, $response); + } + + /** @dataProvider unsafeMethods */ + public function testUnsafeMethodsThrowWithMismatchedHeader(string $method): void + { + $this->expectException(InvalidNonceException::class); + $_SESSION['csrfToken'] = 'abc123'; + $mw = $this->middleware(); + $request = (new ServerRequest($method, '/')) + ->withHeader('X-XSRF-TOKEN', 'wrong-token'); + $mw($request, new Response(), $this->next()); + } + + public static function unsafeMethods(): array + { + return [['POST'], ['PUT'], ['DELETE']]; + } + + public function testPostPassesWithMatchingBodyParam(): void + { + $_SESSION['csrfToken'] = 'abc123'; + $mw = $this->middleware(); + $request = (new ServerRequest('POST', '/')) + ->withParsedBody(['csrfToken' => 'abc123']); + $response = $mw($request, new Response(), $this->next()); + $this->assertInstanceOf(Response::class, $response); + } + + public function testPostThrowsWithMismatchedBodyParam(): void + { + $this->expectException(InvalidNonceException::class); + $_SESSION['csrfToken'] = 'abc123'; + $mw = $this->middleware(); + $request = (new ServerRequest('POST', '/')) + ->withParsedBody(['csrfToken' => 'wrong']); + $mw($request, new Response(), $this->next()); + } + + public function testBodyParamOverridesHeader(): void + { + // Header is present with correct value, but body param overwrites $userToken with wrong value + $this->expectException(InvalidNonceException::class); + $_SESSION['csrfToken'] = 'correct'; + $mw = $this->middleware(); + $request = (new ServerRequest('POST', '/')) + ->withHeader('X-XSRF-TOKEN', 'correct') + ->withParsedBody(['csrfToken' => 'wrong']); // body overwrites userToken last + $mw($request, new Response(), $this->next()); + } + + // ------------------------------------------------------------------ + // Request attributes + // ------------------------------------------------------------------ + + public function testRequestAttributesCsrfKeyAndTokenAreSet(): void + { + $_SESSION['csrfToken'] = 'my-token'; + $mw = $this->middleware(); + + $capturedRequest = null; + $next = function ($request, $response) use (&$capturedRequest) { + $capturedRequest = $request; + return $response; + }; + + $request = new ServerRequest('GET', '/'); + $mw($request, new Response(), $next); + + $this->assertSame('csrfToken', $capturedRequest->getAttribute('csrfKey')); + $this->assertSame('my-token', $capturedRequest->getAttribute('csrfToken')); + } +} diff --git a/tests/Nonce/NonceServiceTest.php b/tests/Nonce/NonceServiceTest.php new file mode 100644 index 0000000..fd66958 --- /dev/null +++ b/tests/Nonce/NonceServiceTest.php @@ -0,0 +1,171 @@ +service = new ConcreteNonceService(); + } + + public function testCreateProducesNonceWithCorrectEntityIdAndAction(): void + { + $nonce = $this->service->create(7, 'login', 300); + $this->assertSame(7, $nonce->entityId); + $this->assertSame('login', $nonce->action); + } + + public function testCreateSetsEmptyMeta(): void + { + $nonce = $this->service->create(1, 'test', 60); + $this->assertSame([], $nonce->meta); + } + + public function testCreateSetsExpiryInFuture(): void + { + $before = time(); + $nonce = $this->service->create(1, 'test', 300); + $after = time(); + $this->assertGreaterThanOrEqual($before + 300, $nonce->expires); + $this->assertLessThanOrEqual($after + 300, $nonce->expires); + } + + public function testHydrateFromArrayPopulatesAllFields(): void + { + $original = $this->service->create(5, 'upload', 3600); + $data = $original->jsonSerialize(); + + $hydrated = $this->service->hydrate($data); + + $this->assertSame($data['entityId'], $hydrated->entityId); + $this->assertSame($data['lookup'], $hydrated->lookup); + $this->assertSame($data['action'], $hydrated->action); + $this->assertSame($data['expires'], $hydrated->expires); + $this->assertSame($data['hashed'], $hydrated->getHashed()); + } + + public function testHydrateFromJsonStringDecodesAndPopulates(): void + { + $original = $this->service->create(5, 'upload', 3600); + $json = json_encode($original->jsonSerialize()); + + $hydrated = $this->service->hydrate($json); + + $this->assertSame($original->entityId, $hydrated->entityId); + $this->assertSame($original->action, $hydrated->action); + } + + public function testHydrateMissingMetaDefaultsToEmptyArray(): void + { + $data = [ + 'entityId' => 1, + 'lookup' => 'abc', + 'action' => 'test', + 'expires' => time() + 3600, + 'hashed' => 'hash', + ]; + + $hydrated = $this->service->hydrate($data); + $this->assertSame([], $hydrated->meta); + } + + public function testGetVerifiedReturnsNonceWhenValid(): void + { + $nonce = $this->service->create(1, 'action', 3600); + $this->service->store[] = $nonce; + + $verified = $this->service->getVerified($nonce->nonce, $nonce->lookup, 'action'); + $this->assertSame($nonce, $verified); + } + + public function testGetVerifiedThrowsOnWrongPlaintext(): void + { + $this->expectException(InvalidNonceException::class); + $nonce = $this->service->create(1, 'action', 3600); + $this->service->store[] = $nonce; + + $this->service->getVerified('wrong-plaintext', $nonce->lookup, 'action'); + } + + public function testGetVerifiedThrowsWhenExpired(): void + { + $this->expectException(InvalidNonceException::class); + $nonce = $this->service->create(1, 'action', -1); + $this->service->store[] = $nonce; + + $this->service->getVerified($nonce->nonce, $nonce->lookup, 'action'); + } + + public function testGetVerifiedThrowsNotFoundWhenLookupMissing(): void + { + $this->expectException(NotFoundException::class); + $this->service->getVerified('nonce', 'nonexistent-lookup', 'action'); + } + + /** + * @todo Bug: action check uses `!$verifyNonce->action === $action` which is parsed as + * `(!$verifyNonce->action) === $action` due to operator precedence. + * This means the check is always false and action mismatch never throws. + * Fix: change to `$verifyNonce->action !== $action`. + */ + public function testGetVerifiedDoesNotThrowOnActionMismatch_currentBuggedBehavior(): void + { + $nonce = $this->service->create(1, 'correct-action', 3600); + $this->service->store[] = $nonce; + + // Should throw InvalidNonceException, but currently does NOT due to the bug + $verified = $this->service->getVerified($nonce->nonce, $nonce->lookup, 'wrong-action'); + $this->assertSame($nonce, $verified); + } + + /** + * @todo This test documents the *intended* behavior once the bug is fixed. + */ + public function testGetVerifiedShouldThrowOnActionMismatch_skippedUntilFixed(): void + { + $this->markTestSkipped('Bug: !$x === $y operator precedence — action check never fires. Fix NonceService line 60.'); + } + + public function testGetSplitVerifiedSplitsOnDefaultDelimiter(): void + { + $nonce = $this->service->create(1, 'action', 3600); + $this->service->store[] = $nonce; + + $complete = $nonce->getCompleteNonce(':::'); + $verified = $this->service->getSplitVerified($complete, 'action'); + $this->assertSame($nonce, $verified); + } + + /** + * @todo Bug: getSplitVerified ignores the $delimiter parameter and always uses ':::'. + * Fix: change explode(':::', $nonce) to explode($delimiter, $nonce) on line 70. + */ + public function testGetSplitVerifiedIgnoresCustomDelimiter_currentBuggedBehavior(): void + { + $nonce = $this->service->create(1, 'action', 3600); + $this->service->store[] = $nonce; + + // Using '|' as delimiter, but the code always splits on ':::' + // So we pass ':::'-delimited string and '|' delimiter — the '|' is silently ignored + $complete = $nonce->getCompleteNonce(':::'); + $verified = $this->service->getSplitVerified($complete, 'action', '|'); + $this->assertSame($nonce, $verified); + } + + /** + * @todo This test documents the *intended* behavior once the bug is fixed. + */ + public function testGetSplitVerifiedShouldUseCustomDelimiter_skippedUntilFixed(): void + { + $this->markTestSkipped('Bug: getSplitVerified ignores $delimiter param. Fix NonceService line 70.'); + } +} diff --git a/tests/Nonce/NonceTest.php b/tests/Nonce/NonceTest.php new file mode 100644 index 0000000..b65ab8b --- /dev/null +++ b/tests/Nonce/NonceTest.php @@ -0,0 +1,140 @@ +setNonce(20, 10); + // bin2hex doubles byte count: 20 bytes → 40 hex chars + $this->assertSame(40, strlen($nonce->nonce)); + } + + public function testSetNonceGeneratesHexLookupOfRequestedLength(): void + { + $nonce = (new Nonce())->setNonce(20, 10); + // 10 bytes → 20 hex chars + $this->assertSame(20, strlen($nonce->lookup)); + } + + public function testSetNonceIsIdempotent(): void + { + $nonce = (new Nonce())->setNonce(20, 10); + $originalNonce = $nonce->nonce; + $originalLookup = $nonce->lookup; + $originalHashed = $nonce->getHashed(); + + $nonce->setNonce(20, 10); + + $this->assertSame($originalNonce, $nonce->nonce); + $this->assertSame($originalLookup, $nonce->lookup); + $this->assertSame($originalHashed, $nonce->getHashed()); + } + + public function testGetHashedReturnsBcryptHash(): void + { + $nonce = (new Nonce())->setNonce(); + $info = password_get_info($nonce->getHashed()); + $this->assertSame(PASSWORD_BCRYPT, $info['algo']); + } + + public function testGetHashedVerifiesAgainstPlaintext(): void + { + $nonce = (new Nonce())->setNonce(); + $this->assertTrue(password_verify($nonce->nonce, $nonce->getHashed())); + } + + public function testSetHashedAllowsManualRehydration(): void + { + $nonce = (new Nonce())->setNonce(); + $plaintext = $nonce->nonce; + $hashed = $nonce->getHashed(); + + $rehydrated = new Nonce(); + $rehydrated->setHashed($hashed); + + $this->assertTrue(password_verify($plaintext, $rehydrated->getHashed())); + } + + public function testGetCompleteNonceUsesDefaultDelimiter(): void + { + $nonce = (new Nonce())->setNonce(); + $complete = $nonce->getCompleteNonce(); + $this->assertStringContainsString(':::', $complete); + $parts = explode(':::', $complete); + $this->assertSame($nonce->nonce, $parts[0]); + $this->assertSame($nonce->lookup, $parts[1]); + } + + public function testGetCompleteNonceUsesCustomDelimiter(): void + { + $nonce = (new Nonce())->setNonce(); + $complete = $nonce->getCompleteNonce('|'); + $parts = explode('|', $complete); + $this->assertSame($nonce->nonce, $parts[0]); + $this->assertSame($nonce->lookup, $parts[1]); + } + + public function testToStringReturnsPlaintextNonce(): void + { + $nonce = (new Nonce())->setNonce(); + $this->assertSame($nonce->nonce, (string) $nonce); + } + + public function testVerifyReturnsTrueForCorrectNonceAndFutureExpiry(): void + { + $nonce = (new Nonce())->setNonce(); + $nonce->expires = time() + 3600; + $this->assertTrue($nonce->verify($nonce->nonce)); + } + + public function testVerifyReturnsFalseForWrongNonce(): void + { + $nonce = (new Nonce())->setNonce(); + $nonce->expires = time() + 3600; + $this->assertFalse($nonce->verify('wrong-value')); + } + + public function testVerifyReturnsFalseWhenExpired(): void + { + $nonce = (new Nonce())->setNonce(); + $nonce->expires = time() - 60; + $this->assertFalse($nonce->verify($nonce->nonce)); + } + + public function testVerifyReturnsTrueAtExactExpiryBoundary(): void + { + $nonce = (new Nonce())->setNonce(); + $nonce->expires = time(); // >= semantics: exactly now is still valid + $this->assertTrue($nonce->verify($nonce->nonce)); + } + + public function testJsonSerializeIncludesExpectedFields(): void + { + $nonce = (new Nonce())->setNonce(); + $nonce->entityId = 42; + $nonce->action = 'login'; + $nonce->expires = time() + 3600; + $nonce->meta = ['key' => 'value']; + + $data = $nonce->jsonSerialize(); + + $this->assertSame(42, $data['entityId']); + $this->assertSame($nonce->getHashed(), $data['hashed']); + $this->assertSame($nonce->lookup, $data['lookup']); + $this->assertSame('login', $data['action']); + $this->assertSame($nonce->expires, $data['expires']); + $this->assertSame(['key' => 'value'], $data['meta']); + } + + public function testJsonSerializeOmitsPlaintextNonce(): void + { + $nonce = (new Nonce())->setNonce(); + $data = $nonce->jsonSerialize(); + $this->assertArrayNotHasKey('nonce', $data); + } +} diff --git a/tests/Sanitizer/RespectSanitizerTest.php b/tests/Sanitizer/RespectSanitizerTest.php new file mode 100644 index 0000000..a5aff4a --- /dev/null +++ b/tests/Sanitizer/RespectSanitizerTest.php @@ -0,0 +1,636 @@ +setCollection($data); + } + + // ------------------------------------------------------------------ + // setCollection / setDefaultOptions / hasParam + // ------------------------------------------------------------------ + + public function testSetCollectionAcceptsArray(): void + { + $s = (new RespectSanitizer())->setCollection(['foo' => 'bar']); + $this->assertTrue($s->hasParam('foo')); + } + + public function testSetCollectionAcceptsCollection(): void + { + $s = (new RespectSanitizer())->setCollection(new Collection(['foo' => 'bar'])); + $this->assertTrue($s->hasParam('foo')); + } + + public function testSetCollectionReturnsSelf(): void + { + $s = new RespectSanitizer(); + $this->assertSame($s, $s->setCollection([])); + } + + public function testSetDefaultOptionsMergesOptions(): void + { + $s = $this->sanitizer(['x' => '']); + $s->setDefaultOptions(['defaultOnEmptyString' => true]); + $this->assertNull($s->getString('x')); + } + + public function testHasParamReturnsTrueWhenPresent(): void + { + $this->assertTrue($this->sanitizer(['a' => 1])->hasParam('a')); + } + + public function testHasParamReturnsFalseWhenMissing(): void + { + $this->assertFalse($this->sanitizer([])->hasParam('missing')); + } + + // ------------------------------------------------------------------ + // getParam + // ------------------------------------------------------------------ + + public function testGetParamReturnsRawStringValue(): void + { + $this->assertSame('hello', $this->sanitizer(['k' => 'hello'])->getParam('k')); + } + + public function testGetParamReturnsRawArrayValue(): void + { + $val = [1, 2, 3]; + $this->assertSame($val, $this->sanitizer(['k' => $val])->getParam('k')); + } + + public function testGetParamReturnsDefaultWhenMissing(): void + { + $this->assertNull($this->sanitizer([])->getParam('missing')); + } + + public function testGetParamReturnsDefaultWhenNull(): void + { + $this->assertNull($this->sanitizer(['k' => null])->getParam('k')); + } + + public function testGetParamReturnsEmptyStringByDefault(): void + { + $this->assertSame('', $this->sanitizer(['k' => ''])->getParam('k')); + } + + public function testGetParamReturnsDefaultOnEmptyStringWhenFlagSet(): void + { + $this->assertNull( + $this->sanitizer(['k' => ''])->getParam('k', ['defaultOnEmptyString' => true]) + ); + } + + public function testGetParamThrowsInvalidArgumentException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->sanitizer([])->getParam('missing', ['defaultOnNotExists' => false]); + } + + public function testGetParamRunsCallableThrow(): void + { + $called = false; + $this->sanitizer([])->getParam('missing', [ + 'throw' => function () use (&$called) { $called = true; } + ]); + $this->assertTrue($called); + } + + public function testGetParamThrowMessageInterpolatesParam(): void + { + try { + $this->sanitizer([])->getParam('myKey', [ + 'defaultOnNotExists' => false, + 'throwMessage' => 'Missing {{param}}', + ]); + $this->fail('Expected exception not thrown'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('myKey', $e->getMessage()); + } + } + + // ------------------------------------------------------------------ + // getInt + // ------------------------------------------------------------------ + + public function testGetIntParsesStringInteger(): void + { + $this->assertSame(42, $this->sanitizer(['n' => '42'])->getInt('n')); + } + + public function testGetIntParsesNativeInteger(): void + { + $this->assertSame(7, $this->sanitizer(['n' => 7])->getInt('n')); + } + + public function testGetIntParsesNegativeInteger(): void + { + $this->assertSame(-5, $this->sanitizer(['n' => '-5'])->getInt('n')); + } + + public function testGetIntParsesZeroString(): void + { + $this->assertSame(0, $this->sanitizer(['n' => '0'])->getInt('n')); + } + + public function testGetIntReturnsNullWhenMissing(): void + { + $this->assertNull($this->sanitizer([])->getInt('n')); + } + + public function testGetIntReturnsNullWhenValueIsNull(): void + { + $this->assertNull($this->sanitizer(['n' => null])->getInt('n')); + } + + public function testGetIntReturnsNullOnEmptyString(): void + { + $this->assertNull($this->sanitizer(['n' => ''])->getInt('n')); + } + + public function testGetIntThrowsOnFloatString(): void + { + $this->expectException(InvalidArgumentException::class); + $this->sanitizer(['n' => '3.14'])->getInt('n', ['defaultOnNotExists' => false]); + } + + public function testGetIntThrowsOnAlphaString(): void + { + $this->expectException(InvalidArgumentException::class); + $this->sanitizer(['n' => 'abc'])->getInt('n', ['defaultOnNotExists' => false]); + } + + public function testGetIntThrowsWhenDefaultOnNotExistsFalseAndMissing(): void + { + $this->expectException(InvalidArgumentException::class); + $this->sanitizer([])->getInt('n', ['defaultOnNotExists' => false]); + } + + public function testGetIntAppliesCustomRulePasses(): void + { + $result = $this->sanitizer(['n' => '11'])->getInt('n', ['rules' => ['Min' => [10]]]); + $this->assertSame(11, $result); + } + + public function testGetIntAppliesCustomRuleFails(): void + { + $this->expectException(InvalidArgumentException::class); + $this->sanitizer(['n' => '5'])->getInt('n', [ + 'defaultOnNotExists' => false, + 'rules' => ['Min' => [10]], + ]); + } + + public function testGetIntUsesThrowClass(): void + { + $this->expectException(CustomThrowException::class); + $this->sanitizer(['n' => 'bad'])->getInt('n', ['throwClass' => CustomThrowException::class]); + } + + public function testGetIntInterpolatesThrowMessage(): void + { + try { + $this->sanitizer(['myParam' => 'bad'])->getInt('myParam', [ + 'throwMessage' => 'Bad value for {{param}}', + ]); + $this->fail('Expected exception'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('myParam', $e->getMessage()); + } + } + + public function testGetIntCallableThrowExecuted(): void + { + $called = false; + $this->sanitizer(['n' => 'bad'])->getInt('n', [ + 'throw' => function () use (&$called) { $called = true; } + ]); + $this->assertTrue($called); + } + + // ------------------------------------------------------------------ + // getDouble + // ------------------------------------------------------------------ + + public function testGetDoubleParsesFloatString(): void + { + $this->assertSame(3.14, $this->sanitizer(['d' => '3.14'])->getDouble('d')); + } + + public function testGetDoubleParseIntegerString(): void + { + $this->assertSame(42.0, $this->sanitizer(['d' => '42'])->getDouble('d')); + } + + public function testGetDoubleReturnsNullOnEmptyString(): void + { + $this->assertNull($this->sanitizer(['d' => ''])->getDouble('d')); + } + + public function testGetDoubleThrowsOnAlphaString(): void + { + $this->expectException(InvalidArgumentException::class); + $this->sanitizer(['d' => 'abc'])->getDouble('d', ['defaultOnNotExists' => false]); + } + + public function testGetDoubleReturnsNullWhenMissing(): void + { + $this->assertNull($this->sanitizer([])->getDouble('d')); + } + + public function testGetDoubleCustomRulePasses(): void + { + $result = $this->sanitizer(['d' => '5.5'])->getDouble('d', ['rules' => ['Min' => [5]]]); + $this->assertSame(5.5, $result); + } + + public function testGetDoubleCustomRuleFails(): void + { + $this->expectException(InvalidArgumentException::class); + $this->sanitizer(['d' => '3.0'])->getDouble('d', [ + 'defaultOnNotExists' => false, + 'rules' => ['Min' => [5]], + ]); + } + + public function testGetDoubleThrowsWhenMissingAndDefaultOnNotExistsFalse(): void + { + $this->expectException(InvalidArgumentException::class); + $this->sanitizer([])->getDouble('d', ['defaultOnNotExists' => false]); + } + + public function testGetDoubleUsesThrowClass(): void + { + $this->expectException(CustomThrowException::class); + $this->sanitizer(['d' => 'bad'])->getDouble('d', ['throwClass' => CustomThrowException::class]); + } + + // ------------------------------------------------------------------ + // getString + // ------------------------------------------------------------------ + + public function testGetStringReturnsPlainString(): void + { + $this->assertSame('hello world', $this->sanitizer(['s' => 'hello world'])->getString('s')); + } + + public function testGetStringStripsHtmlTags(): void + { + $this->assertSame('alert(1)', $this->sanitizer(['s' => ''])->getString('s')); + } + + public function testGetStringPreservesAmpersand(): void + { + // strip_tags does NOT encode entities — Tom & Jerry stays as-is + $this->assertSame('Tom & Jerry', $this->sanitizer(['s' => 'Tom & Jerry'])->getString('s')); + } + + public function testGetStringPreservesQuotes(): void + { + $this->assertSame('say "hi"', $this->sanitizer(['s' => 'say "hi"'])->getString('s')); + } + + public function testGetStringReturnsEmptyStringByDefault(): void + { + $this->assertSame('', $this->sanitizer(['s' => ''])->getString('s')); + } + + public function testGetStringReturnsDefaultOnEmptyStringWhenFlagSet(): void + { + $this->assertNull( + $this->sanitizer(['s' => ''])->getString('s', ['defaultOnEmptyString' => true]) + ); + } + + public function testGetStringReturnsNullWhenNull(): void + { + $this->assertNull($this->sanitizer(['s' => null])->getString('s')); + } + + public function testGetStringReturnsNullWhenMissing(): void + { + $this->assertNull($this->sanitizer([])->getString('s')); + } + + public function testGetStringThrowsOnNonString(): void + { + $this->expectException(InvalidArgumentException::class); + $this->sanitizer(['s' => 123])->getString('s', ['defaultOnNotExists' => false]); + } + + public function testGetStringCustomLengthRulePasses(): void + { + $result = $this->sanitizer(['s' => 'hello'])->getString('s', ['rules' => ['Length' => [3, 10]]]); + $this->assertSame('hello', $result); + } + + public function testGetStringCustomLengthRuleFails(): void + { + $this->expectException(InvalidArgumentException::class); + $this->sanitizer(['s' => 'hi'])->getString('s', [ + 'defaultOnNotExists' => false, + 'rules' => ['Length' => [3, 10]], + ]); + } + + public function testGetStringUsesThrowClass(): void + { + $this->expectException(CustomThrowException::class); + $this->sanitizer(['s' => 123])->getString('s', ['throwClass' => CustomThrowException::class]); + } + + public function testGetStringInterpolatesThrowMessage(): void + { + try { + $this->sanitizer(['myStr' => 123])->getString('myStr', [ + 'throwMessage' => 'Bad {{param}}', + ]); + $this->fail('Expected exception'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('myStr', $e->getMessage()); + } + } + + // ------------------------------------------------------------------ + // getDate + // ------------------------------------------------------------------ + + public function testGetDateReturnsCarbonInstanceAsIs(): void + { + $carbon = Carbon::parse('2024-06-01 12:00:00'); + $result = $this->sanitizer(['d' => $carbon])->getDate('d'); + $this->assertSame($carbon, $result); + } + + public function testGetDateParsesValidDateString(): void + { + $result = $this->sanitizer(['d' => '2024-01-15 10:00:00'])->getDate('d'); + $this->assertInstanceOf(Carbon::class, $result); + $this->assertSame('2024-01-15', $result->toDateString()); + } + + public function testGetDateUsesCustomDateFormat(): void + { + $result = $this->sanitizer(['d' => '2024-03-20']) + ->getDate('d', ['dateFormat' => 'Y-m-d']); + $this->assertInstanceOf(Carbon::class, $result); + $this->assertSame('2024-03-20', $result->toDateString()); + } + + public function testGetDateThrowsOnMalformedDate(): void + { + $this->expectException(InvalidArgumentException::class); + $this->sanitizer(['d' => 'not-a-date'])->getDate('d', ['defaultOnNotExists' => false]); + } + + public function testGetDateReturnsDefaultOnEmptyString(): void + { + // getDate uses loose == null, so empty string is treated as missing + $this->assertNull($this->sanitizer(['d' => ''])->getDate('d')); + } + + public function testGetDateReturnsNullWhenNull(): void + { + $this->assertNull($this->sanitizer(['d' => null])->getDate('d')); + } + + public function testGetDateReturnsNullWhenMissing(): void + { + $this->assertNull($this->sanitizer([])->getDate('d')); + } + + public function testGetDateUsesThrowClass(): void + { + $this->expectException(CustomThrowException::class); + $this->sanitizer(['d' => 'bad'])->getDate('d', ['throwClass' => CustomThrowException::class]); + } + + public function testGetDateThrowsOnWrongFormat(): void + { + $this->expectException(InvalidArgumentException::class); + // Date is valid but format doesn't match + $this->sanitizer(['d' => '01/15/2024'])->getDate('d', [ + 'defaultOnNotExists' => false, + 'dateFormat' => 'Y-m-d H:i:s', + ]); + } + + // ------------------------------------------------------------------ + // getArray + // ------------------------------------------------------------------ + + public function testGetArrayReturnsArrayUnchanged(): void + { + $arr = ['a' => 1, 'b' => 2]; + $this->assertSame($arr, $this->sanitizer(['arr' => $arr])->getArray('arr')); + } + + public function testGetArrayReturnsEmptyArray(): void + { + $this->assertSame([], $this->sanitizer(['arr' => []])->getArray('arr')); + } + + public function testGetArrayThrowsOnNonArray(): void + { + $this->expectException(InvalidArgumentException::class); + $this->sanitizer(['arr' => 'nope'])->getArray('arr', ['defaultOnNotExists' => false]); + } + + public function testGetArrayReturnsNullWhenValueIsNull(): void + { + $this->assertNull($this->sanitizer(['arr' => null])->getArray('arr')); + } + + public function testGetArrayReturnsNullWhenMissing(): void + { + $this->assertNull($this->sanitizer([])->getArray('arr')); + } + + public function testGetArrayPreservesNestedArrays(): void + { + $arr = ['x' => ['y' => ['z' => 99]]]; + $this->assertSame($arr, $this->sanitizer(['arr' => $arr])->getArray('arr')); + } + + // ------------------------------------------------------------------ + // getIntArray + // ------------------------------------------------------------------ + + public function testGetIntArrayConvertsStringIntegers(): void + { + $this->assertSame([1, 2, 3], $this->sanitizer(['arr' => ['1', '2', '3']])->getIntArray('arr')); + } + + public function testGetIntArrayCoercesNonNumericToZero(): void + { + // intval('abc') === 0 — document this behavior + $this->assertSame([1, 0], $this->sanitizer(['arr' => ['1', 'abc']])->getIntArray('arr')); + } + + public function testGetIntArrayThrowsOnNonArray(): void + { + $this->expectException(InvalidArgumentException::class); + $this->sanitizer(['arr' => 'nope'])->getIntArray('arr', ['defaultOnNotExists' => false]); + } + + public function testGetIntArrayReturnsNullWhenMissing(): void + { + $this->assertNull($this->sanitizer([])->getIntArray('arr')); + } + + public function testGetIntArrayReturnsNullWhenNull(): void + { + $this->assertNull($this->sanitizer(['arr' => null])->getIntArray('arr')); + } + + // ------------------------------------------------------------------ + // getCheckbox + // ------------------------------------------------------------------ + + /** @dataProvider checkboxTruthyProvider */ + public function testGetCheckboxReturnsTrueForTruthyValues(mixed $value): void + { + $this->assertTrue($this->sanitizer(['cb' => $value])->getCheckbox('cb')); + } + + public static function checkboxTruthyProvider(): array + { + return [['on'], [1], ['1'], ['true'], [true]]; + } + + /** @dataProvider checkboxFalsyProvider */ + public function testGetCheckboxReturnsFalseForFalsyValues(mixed $value): void + { + $this->assertFalse($this->sanitizer(['cb' => $value])->getCheckbox('cb')); + } + + public static function checkboxFalsyProvider(): array + { + return [['off'], ['0'], [0], [''], [false], ['ON']]; // 'ON' is case-sensitive + } + + public function testGetCheckboxReturnsFalseWhenMissingWithNullDefault(): void + { + $this->assertFalse($this->sanitizer([])->getCheckbox('cb')); + } + + public function testGetCheckboxReturnsTrueWhenMissingWithTrueDefault(): void + { + // $options['default'] != null: true != null is true so default is used + $this->assertTrue($this->sanitizer([])->getCheckbox('cb', ['default' => true])); + } + + public function testGetCheckboxReturnIntegerOneWhenFlagSet(): void + { + $this->assertSame(1, $this->sanitizer(['cb' => 'on'])->getCheckbox('cb', ['checkboxReturnInteger' => true])); + } + + public function testGetCheckboxReturnIntegerZeroWhenFlagSet(): void + { + $this->assertSame(0, $this->sanitizer(['cb' => ''])->getCheckbox('cb', ['checkboxReturnInteger' => true])); + } + + public function testGetCheckboxNeverThrowsEvenWhenMissing(): void + { + // No exception even with defaultOnNotExists=false + $result = $this->sanitizer([])->getCheckbox('cb', ['defaultOnNotExists' => false]); + $this->assertFalse($result); + } + + // ------------------------------------------------------------------ + // getHtml + // ------------------------------------------------------------------ + + public function testGetHtmlPreservesSafeTags(): void + { + $result = $this->sanitizer(['h' => '

hello

'])->getHtml('h'); + $this->assertStringContainsString('hello', $result); + $this->assertStringContainsString('

', $result); + } + + public function testGetHtmlStripsScriptTags(): void + { + $result = $this->sanitizer(['h' => ''])->getHtml('h'); + $this->assertStringNotContainsString('