diff --git a/VERSION b/VERSION index 12a74d7..4fe2fe8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.39 +0.0.40 diff --git a/changelog.md b/changelog.md index 116ca70..142cea4 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,11 @@ # Changelog SPIN Framework Changelog +## 0.0.40 +- **Feature:** Add domain exception classes: `CacheException`, `ConfigException`, `DatabaseException`, `MiddlewareException` — all extending `SpinException` +- **Feature:** Wire `CacheException` into cache adapters (APCu, Redis), `ConfigException` into `Config`, `DatabaseException` into `PdoConnection` and `ConnectionManager` +- **Fix:** `ConnectionManager` password-sanitization now throws `DatabaseException` without `$previous` on tainted traces, preventing credential leakage + ## 0.0.39 - **Feature:** `.env` file auto-loading — `DotEnv::load()` reads `.env` from the project root at startup, populating environment variables before config macro expansion and `env()` calls; real process env vars always take precedence - **Fix:** `${env:VAR:default}` inline default syntax in config macros now works as documented; missing variables now resolve to the specified default instead of silently dropping to empty string diff --git a/composer.json b/composer.json index 5242e8b..c257b1c 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "celarius/spin-framework", "description": "A super lightweight PHP UI/REST Framework", - "version": "0.0.39", + "version": "0.0.40", "keywords": [ "php8", "php-framework", diff --git a/doc/User-Guide/Helpers.md b/doc/User-Guide/Helpers.md index 53c500d..2f48f2f 100644 --- a/doc/User-Guide/Helpers.md +++ b/doc/User-Guide/Helpers.md @@ -28,6 +28,8 @@ * [2.2. Cipher](#22-cipher) * [2.3. Hash](#23-hash) * [2.4. UUID](#24-uuid) +* [2.5. JWT](#25-jwt) +* [2.6. EWT](#26-ewt) @@ -306,42 +308,243 @@ return responseFile($filename); --- # 2. Global Helper Objects +Helper classes live under the `Spin\Helpers` namespace. All methods are static unless noted otherwise. + ## 2.1. ArrayToXml -The `ArrayToXml()` converts an `$array` to a `$xml` document. + +Converts a PHP associative array to an XML document string. ```php -$array = []; -$array['key'] = 'value'; -# Convert array to XML document -$xml = (new \Spin\Helpers\ArrayToXml())->buildXML($array); +public function __construct(string $xmlVersion = '1.0', string $xmlEncoding = 'UTF-8') +public function buildXML(array $data, string $startElement = 'data'): string|false +``` + +Returns the XML as a Base64-encoded string on success, `false` on failure. + +**Special key prefixes** control how array keys are mapped to XML: + +| Prefix | Effect | Example key | +|--------|--------|-------------| +| `@` | XML attribute on the parent element | `@id` | +| `%` | Text content of the parent element | `%` | +| `#` | CDATA section | `#note` | +| `!` | CDATA section (alternative) | `!note` | + +```php +# Basic usage +$xml = (new \Spin\Helpers\ArrayToXml())->buildXML([ + 'name' => 'Alice', + 'age' => 30, +]); + +# With XML attributes and CDATA +$xml = (new \Spin\Helpers\ArrayToXml())->buildXML([ + '@id' => 42, // becomes + 'name' => 'Alice', + 'bio' => [ + '#content' => 'Contains & chars', // wrapped in CDATA + ], +], 'user'); ``` +--- + ## 2.2. Cipher -The `Cipher` helper encrypts/decrypts strings using OpenSSL. + +Encrypts and decrypts strings using OpenSSL. All methods are static. + +```php +public static function encrypt(string $data, string $secret = '', string $algorithm = 'AES-256-CBC'): string +public static function decrypt(string $data, string $secret = '', string $algorithm = 'AES-256-CBC'): bool|string +public static function encryptEx(string $data, string $secret, string $cipher = 'aes-256-ctr', string $hashAlgo = 'sha3-512'): string +public static function decryptEx(string $input, string $secret): mixed +public static function getMethods(): array +``` + +`encrypt` / `decrypt` use a random IV and return Base64-encoded ciphertext. + +`encryptEx` / `decryptEx` add an HMAC signature. The token format is: +``` +cipher[hashAlgo]:base64(iv).base64(encrypted).base64(hmac) +``` +`decryptEx` throws an `\Exception` if the HMAC does not verify. ```php -# Encrypt a value -$encryptedValue = \Spin\Helpers\Cipher::encrypt( $plain, 'secret', 'AES-256-CBC' ); +# Basic encrypt / decrypt +$secret = env('APP_SECRET'); +$encrypted = \Spin\Helpers\Cipher::encrypt('sensitive data', $secret); +$plain = \Spin\Helpers\Cipher::decrypt($encrypted, $secret); + +# Extended encrypt / decrypt (with HMAC verification) +$token = \Spin\Helpers\Cipher::encryptEx('sensitive data', $secret); +try { + $plain = \Spin\Helpers\Cipher::decryptEx($token, $secret); +} catch (\Exception $e) { + // tampered or wrong secret +} -# Decrypt a value -$plain = \Spin\Helpers\Cipher::decrypt( $encryptedValue, 'secret', 'AES-256-CBC' ); +# List available OpenSSL cipher methods +$methods = \Spin\Helpers\Cipher::getMethods(); ``` +--- + ## 2.3. Hash -The `Hash` helper produces hashes using OpenSSL digest methods. + +Generates and verifies cryptographic digests using OpenSSL. All methods are static. + +```php +public static function generate(string $data, string $method = 'SHA256'): string +public static function check(string $data, string $hash, string $method = 'SHA256'): bool +public static function getMethods(): array +``` + +`check` uses a timing-safe string comparison to prevent timing attacks. ```php -# Produce a hash -$digest = \Spin\Helpers\Hash::generate('This is the data','SHA256'); +# Generate a hash +$hash = \Spin\Helpers\Hash::generate('my data'); + +# Verify a hash +if (\Spin\Helpers\Hash::check('my data', $hash)) { + // data is unchanged +} + +# List available OpenSSL digest methods +$methods = \Spin\Helpers\Hash::getMethods(); ``` +--- + ## 2.4. UUID -The `UUID` helper produces UUID v3, v4 and v5 unique UUID's. + +Generates and validates UUIDs using the Ramsey UUID library. All methods are static. + +```php +public static function generate(): string // v7 — default +public static function v3(string $namespace, string $name): string +public static function v4(): string +public static function v5(string $namespace, string $name): string +public static function v6(): string +public static function v7(): string +public static function is_valid(string $uuid): bool +``` + +| Version | Algorithm | Use case | +|---------|-----------|----------| +| v3 | Namespace + MD5 | Deterministic from a name | +| v4 | Random | General-purpose unique IDs | +| v5 | Namespace + SHA1 | Deterministic, collision-resistant | +| v6 | Time-based (legacy) | Ordered, backwards-compatible with v1 | +| v7 | Time-based (recommended) | Ordered, sortable, database-friendly | + +`generate()` returns a v7 UUID. Prefer v7 for database primary keys (monotonically increasing, index-friendly). Use v4 when ordering is irrelevant. + +`is_valid` validates UUIDs of versions v3, v4, and v5. + +```php +# Default (v7 — time-ordered, ideal for DB primary keys) +$id = \Spin\Helpers\UUID::generate(); + +# Random v4 +$id = \Spin\Helpers\UUID::v4(); + +# Deterministic v5 (same namespace + name always yields the same UUID) +$ns = \Spin\Helpers\UUID::v4(); // generate a namespace once, store it +$id = \Spin\Helpers\UUID::v5($ns, 'user@example.com'); + +# Validate +if (\Spin\Helpers\UUID::is_valid($id)) { + // valid UUID +} +``` + +--- + +## 2.5. JWT + +Signs and verifies JSON Web Tokens via the `firebase/php-jwt` library. All methods are static. + +```php +public static function encode(array $payload, string $key, string $alg, ?string $keyId = null, ?array $head = null): string +public static function decode(string $jwt, string $key, string $algo = 'HS256'): array +public static function sign(string $msg, string $key, string $alg): string +``` + +**Supported algorithms:** `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384` + +**Standard payload claims:** + +| Claim | Type | Description | +|-------|------|-------------| +| `sub` | string | Subject (e.g. user ID) | +| `iat` | int | Issued-at timestamp | +| `exp` | int | Expiry timestamp | +| `nbf` | int | Not-before timestamp | ```php -# UUIDv4 GUID -$uuidv4 = \Spin\Helpers\UUID::generate(); +$secret = env('APP_SECRET'); -# UUIDv5 GUID -$uuidv5 = \Spin\Helpers\UUID::v5($uuidv4,'My v5 UUID'); +# Create a token +$token = \Spin\Helpers\JWT::encode([ + 'sub' => (string) $userId, + 'iat' => time(), + 'exp' => time() + 3600, +], $secret, 'HS256'); + +# Decode and verify +try { + $payload = \Spin\Helpers\JWT::decode($token, $secret, 'HS256'); + $userId = $payload['sub']; +} catch (\Exception $e) { + // invalid signature, expired, or malformed token +} + +# Middleware pattern — extract Bearer token and validate +$authHeader = getRequest()->getHeaderLine('Authorization'); +if (str_starts_with($authHeader, 'Bearer ')) { + $token = substr($authHeader, 7); + $payload = \Spin\Helpers\JWT::decode($token, $secret, 'HS256'); + container('jwt_claims', $payload); +} +``` + +--- + +## 2.6. EWT + +EWT (Encrypted Web Token) is a custom token format where the payload is **encrypted** (not just signed). Use EWT instead of JWT when the payload contains sensitive data that must not be readable by the client. + +Unlike JWT (which only signs), EWT encrypts the payload with AES and includes an HMAC signature. `decode` returns `null` if signature verification fails rather than throwing. + +```php +public static function encode(mixed $data, string $secret, string $hash = 'sha256', string $alg = 'aes-256-ctr', ?string $iv = null): string +public static function decode(string $data, string $secret): string|null +public static function base64url_encode(mixed $input): string +public static function base64url_decode(string $input): mixed ``` + +Token format: `base64url(header).base64url(encrypted_payload).base64url(hmac_signature)` + +```php +$secret = env('APP_SECRET'); + +# Encrypt a payload +$token = \Spin\Helpers\EWT::encode(['user_id' => 42, 'role' => 'admin'], $secret); + +# Decrypt and verify +$payload = \Spin\Helpers\EWT::decode($token, $secret); +if ($payload === null) { + // tampered token or wrong secret +} +$data = json_decode($payload, true); +``` + +**JWT vs EWT — when to use which:** + +| | JWT | EWT | +|---|-----|-----| +| Payload visible to client? | Yes (Base64-decoded) | No (encrypted) | +| Signature verified? | Yes | Yes (HMAC) | +| Use when | Claims can be public | Claims are sensitive | +| Example | Auth roles, user ID | Internal session data, PII | diff --git a/package.json b/package.json index 69516ec..897b5cc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "spin-framework", "title": "Spin Framework", - "version": "0.0.39", + "version": "0.0.40", "homepage": "https://github.com/Celarius/spin-framework", "description": "A super lightweight PHP UI/REST Framework", "author": { diff --git a/src/Cache/Adapters/Apcu.php b/src/Cache/Adapters/Apcu.php index 095519e..663ccbd 100644 --- a/src/Cache/Adapters/Apcu.php +++ b/src/Cache/Adapters/Apcu.php @@ -11,6 +11,7 @@ namespace Spin\Cache\Adapters; use Spin\Cache\AbstractCacheAdapter; +use \Spin\Exceptions\CacheException; class Apcu extends AbstractCacheAdapter { @@ -21,7 +22,7 @@ public function __construct(array $options = []) # Check if APCu extension is loaded and available if (!\extension_loaded('apcu') || \ini_get('apc.enabled') !== '1') { - throw new \RuntimeException(sprintf('Cache driver %s not available', $this->getDriver())); + throw new CacheException(sprintf('Cache driver %s not available', $this->getDriver())); } # Set the version of the APCu library diff --git a/src/Cache/Adapters/Redis.php b/src/Cache/Adapters/Redis.php index ef11086..261940e 100644 --- a/src/Cache/Adapters/Redis.php +++ b/src/Cache/Adapters/Redis.php @@ -27,6 +27,7 @@ use Spin\Cache\AbstractCacheAdapter; use Predis\Client as RedisClient; +use \Spin\Exceptions\CacheException; class Redis extends AbstractCacheAdapter { @@ -43,7 +44,7 @@ class Redis extends AbstractCacheAdapter public function __construct(array $connectionDetails = []) { if (($connectionDetails['options'] ?? null) === null) { - throw new \RuntimeException("Empty Redis connection options"); + throw new CacheException("Empty Redis connection options"); } # Set $driver and $connectionDetails diff --git a/src/Core/Config.php b/src/Core/Config.php index 365c0e4..513c1ba 100644 --- a/src/Core/Config.php +++ b/src/Core/Config.php @@ -15,7 +15,7 @@ namespace Spin\Core; use \Exception; -use \Spin\Exceptions\SpinException; +use \Spin\Exceptions\ConfigException; /** * Loads, merges, and persists environment-specific JSON configuration for the @@ -44,7 +44,7 @@ class Config extends AbstractBaseClass implements ConfigInterface * @param string $appPath Path to the /app folder * @param string $environment Name of the environment * - * @throws Exception + * @throws ConfigException */ public function __construct(string $appPath, string $environment) { @@ -98,13 +98,13 @@ public function load(string $filename): self try { $configArray = \json_decode(\file_get_contents($filename), true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new SpinException(sprintf("Invalid JSON file %s, error was %s", $filename, $e->getMessage())); + throw new ConfigException(sprintf("Invalid JSON file %s, error was %s", $filename, $e->getMessage()), 0, $e); } if ($configArray) { $this->confValues = $this->replaceEnvMacros($configArray); } else { - throw new SpinException('Invalid JSON file "' . $filename . '"'); + throw new ConfigException('Invalid JSON file "' . $filename . '"'); } } @@ -130,14 +130,14 @@ public function loadAndMerge(string $filename): self try { $configArray = \json_decode(\file_get_contents($filename), true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new SpinException(sprintf("Invalid JSON file %s, error was %s", $filename, $e->getMessage())); + throw new ConfigException(sprintf("Invalid JSON file %s, error was %s", $filename, $e->getMessage()), 0, $e); } if ($configArray) { # Merge the Config with existing config $this->confValues = $this->replaceEnvMacros(\array_replace_recursive($this->confValues, $configArray)); } else { - throw new SpinException('Invalid JSON file "' . $filename . '"'); + throw new ConfigException('Invalid JSON file "' . $filename . '"'); } } diff --git a/src/Core/ConnectionManager.php b/src/Core/ConnectionManager.php index 95d657b..6b609db 100644 --- a/src/Core/ConnectionManager.php +++ b/src/Core/ConnectionManager.php @@ -27,6 +27,7 @@ use \Spin\Core\ConnectionManagerInterface; use \Spin\Database\PdoConnection; use \Spin\Database\PdoConnectionInterface; +use \Spin\Exceptions\DatabaseException; /** * Centralized factory/pool for database connections. Resolves connection @@ -178,10 +179,11 @@ protected function createConnection(string $connectionName, array $params=[]) # throw new exception only if trace is modified if($beforeTraceLength != \strlen($trace)) { - # new exception - throw new \Exception($e->getMessage(), $e->getCode()); + # new exception without $previous — intentionally omitted to prevent + # password leakage via getPrevious()->getTraceAsString() + throw new DatabaseException($e->getMessage(), (int)$e->getCode()); } - throw $e; + throw new DatabaseException($e->getMessage(), (int)$e->getCode(), $e); } } } diff --git a/src/Database/PdoConnection.php b/src/Database/PdoConnection.php index edb0284..9832c7c 100644 --- a/src/Database/PdoConnection.php +++ b/src/Database/PdoConnection.php @@ -15,6 +15,8 @@ namespace Spin\Database; +use \Spin\Exceptions\DatabaseException; + class PdoConnection extends \PDO implements PdoConnectionInterface { /** @@ -494,7 +496,7 @@ public function rawQuery(string $sql, array $params=[], bool $autoTransactions=t $this->rollBack(); } - throw $e; + throw new DatabaseException($e->getMessage(), (int)$e->getCode(), $e); } return $rows; @@ -545,7 +547,7 @@ public function rawExec(string $sql, array $params = [], bool $autoTransactions $this->rollBack(); } - throw $e; + throw new DatabaseException($e->getMessage(), (int)$e->getCode(), $e); } return $result; diff --git a/src/Database/PdoConnectionInterface.php b/src/Database/PdoConnectionInterface.php index 8bc85d8..d529540 100644 --- a/src/Database/PdoConnectionInterface.php +++ b/src/Database/PdoConnectionInterface.php @@ -14,6 +14,8 @@ namespace Spin\Database; +use \Spin\Exceptions\DatabaseException; + interface PdoConnectionInterface { # PDO Class Interface (must be commented out because of declaration errors) @@ -267,7 +269,7 @@ public function setClientVersion(string $clientVersion): PdoConnectionInterface; * * @return array Array with fetched rows * - * @throws \Exception + * @throws DatabaseException */ public function rawQuery(string $sql, array $params = [], bool $autoTransactions = true): array; @@ -280,7 +282,7 @@ public function rawQuery(string $sql, array $params = [], bool $autoTransactions * * @return null|int `null` or number of rows affected * - * @throws \Exception + * @throws DatabaseException */ public function rawExec(string $sql, array $params = [], bool $autoTransactions = true): ?int; } diff --git a/src/Exceptions/CacheException.php b/src/Exceptions/CacheException.php new file mode 100644 index 0000000..91c06fe --- /dev/null +++ b/src/Exceptions/CacheException.php @@ -0,0 +1,20 @@ +markTestSkipped('Cannot test APCu unavailable scenario when APCu is available'); } - $this->expectException(\RuntimeException::class); + $this->expectException(CacheException::class); $this->expectExceptionMessage('Cache driver APCu not available'); new Apcu(); diff --git a/tests/Cache/Adapters/RedisTest.php b/tests/Cache/Adapters/RedisTest.php index 68b0e58..a28be02 100644 --- a/tests/Cache/Adapters/RedisTest.php +++ b/tests/Cache/Adapters/RedisTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use Psr\SimpleCache\InvalidArgumentException; use Spin\Cache\Adapters\Redis; +use Spin\Exceptions\CacheException; class RedisTest extends TestCase { @@ -44,7 +45,7 @@ public function testRedisAdapterCreated(): void public function testRedisConnectionOptionsRequired(): void { - $this->expectException(\RuntimeException::class); + $this->expectException(CacheException::class); $this->expectExceptionMessage('Empty Redis connection options'); new Redis([]); diff --git a/tests/Database/Drivers/Pdo/MySqlTest.php b/tests/Database/Drivers/Pdo/MySqlTest.php index c96dfaf..39cba3a 100644 --- a/tests/Database/Drivers/Pdo/MySqlTest.php +++ b/tests/Database/Drivers/Pdo/MySqlTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use Spin\Database\Drivers\Pdo\MySql; +use Spin\Exceptions\DatabaseException; class MySqlTest extends TestCase { @@ -296,6 +297,18 @@ public function testAutoTransactionHandling(): void $this->assertEquals(1, $affectedRows); } + public function testRawQueryThrowsDatabaseException(): void + { + $this->expectException(DatabaseException::class); + $this->connection->rawQuery('SELECT * FROM nonexistent_table_xyz_abc', []); + } + + public function testRawExecThrowsDatabaseException(): void + { + $this->expectException(DatabaseException::class); + $this->connection->rawExec('INSERT INTO nonexistent_table_xyz_abc (col) VALUES (:val)', [':val' => 'x']); + } + } diff --git a/tests/Exceptions/CacheExceptionTest.php b/tests/Exceptions/CacheExceptionTest.php new file mode 100644 index 0000000..0a6e4f4 --- /dev/null +++ b/tests/Exceptions/CacheExceptionTest.php @@ -0,0 +1,54 @@ +expectException(CacheException::class); + throw new CacheException('Cache adapter not available'); + } + + public function testCacheExceptionExtendsSpinException(): void + { + $this->assertInstanceOf(SpinException::class, new CacheException('test')); + } + + public function testCacheExceptionExtendsBaseException(): void + { + $this->assertInstanceOf(\Exception::class, new CacheException('test')); + } + + public function testCacheExceptionPreservesMessage(): void + { + $e = new CacheException('Cache driver Redis not available'); + $this->assertSame('Cache driver Redis not available', $e->getMessage()); + } + + public function testCacheExceptionPreservesCode(): void + { + $this->assertSame(42, (new CacheException('error', 42))->getCode()); + } + + public function testCacheExceptionPreservesPrevious(): void + { + $previous = new \RuntimeException('original'); + $this->assertSame($previous, (new CacheException('wrapper', 0, $previous))->getPrevious()); + } + + public function testCacheExceptionCanBeCaughtAsSpinException(): void + { + $caught = null; + try { + throw new CacheException('test'); + } catch (SpinException $e) { + $caught = $e; + } + $this->assertInstanceOf(CacheException::class, $caught); + } +} diff --git a/tests/Exceptions/ConfigExceptionTest.php b/tests/Exceptions/ConfigExceptionTest.php new file mode 100644 index 0000000..a03fca1 --- /dev/null +++ b/tests/Exceptions/ConfigExceptionTest.php @@ -0,0 +1,54 @@ +expectException(ConfigException::class); + throw new ConfigException('Invalid JSON file config.json'); + } + + public function testConfigExceptionExtendsSpinException(): void + { + $this->assertInstanceOf(SpinException::class, new ConfigException('test')); + } + + public function testConfigExceptionExtendsBaseException(): void + { + $this->assertInstanceOf(\Exception::class, new ConfigException('test')); + } + + public function testConfigExceptionPreservesMessage(): void + { + $e = new ConfigException('Invalid JSON file "config-production.json"'); + $this->assertSame('Invalid JSON file "config-production.json"', $e->getMessage()); + } + + public function testConfigExceptionPreservesCode(): void + { + $this->assertSame(500, (new ConfigException('error', 500))->getCode()); + } + + public function testConfigExceptionPreservesPrevious(): void + { + $previous = new \JsonException('json error'); + $this->assertSame($previous, (new ConfigException('wrapper', 0, $previous))->getPrevious()); + } + + public function testConfigExceptionCanBeCaughtAsSpinException(): void + { + $caught = null; + try { + throw new ConfigException('test'); + } catch (SpinException $e) { + $caught = $e; + } + $this->assertInstanceOf(ConfigException::class, $caught); + } +} diff --git a/tests/Exceptions/DatabaseExceptionTest.php b/tests/Exceptions/DatabaseExceptionTest.php new file mode 100644 index 0000000..5766926 --- /dev/null +++ b/tests/Exceptions/DatabaseExceptionTest.php @@ -0,0 +1,54 @@ +expectException(DatabaseException::class); + throw new DatabaseException('Query execution failed'); + } + + public function testDatabaseExceptionExtendsSpinException(): void + { + $this->assertInstanceOf(SpinException::class, new DatabaseException('test')); + } + + public function testDatabaseExceptionExtendsBaseException(): void + { + $this->assertInstanceOf(\Exception::class, new DatabaseException('test')); + } + + public function testDatabaseExceptionPreservesMessage(): void + { + $e = new DatabaseException('SQLSTATE[HY000]: General error'); + $this->assertSame('SQLSTATE[HY000]: General error', $e->getMessage()); + } + + public function testDatabaseExceptionPreservesCode(): void + { + $this->assertSame(1045, (new DatabaseException('error', 1045))->getCode()); + } + + public function testDatabaseExceptionPreservesPrevious(): void + { + $previous = new \PDOException('pdo error'); + $this->assertSame($previous, (new DatabaseException('wrapper', 0, $previous))->getPrevious()); + } + + public function testDatabaseExceptionCanBeCaughtAsSpinException(): void + { + $caught = null; + try { + throw new DatabaseException('test'); + } catch (SpinException $e) { + $caught = $e; + } + $this->assertInstanceOf(DatabaseException::class, $caught); + } +} diff --git a/tests/Exceptions/MiddlewareExceptionTest.php b/tests/Exceptions/MiddlewareExceptionTest.php new file mode 100644 index 0000000..4596f40 --- /dev/null +++ b/tests/Exceptions/MiddlewareExceptionTest.php @@ -0,0 +1,54 @@ +expectException(MiddlewareException::class); + throw new MiddlewareException('Middleware initialization failed'); + } + + public function testMiddlewareExceptionExtendsSpinException(): void + { + $this->assertInstanceOf(SpinException::class, new MiddlewareException('test')); + } + + public function testMiddlewareExceptionExtendsBaseException(): void + { + $this->assertInstanceOf(\Exception::class, new MiddlewareException('test')); + } + + public function testMiddlewareExceptionPreservesMessage(): void + { + $e = new MiddlewareException('Authentication middleware failed to initialize'); + $this->assertSame('Authentication middleware failed to initialize', $e->getMessage()); + } + + public function testMiddlewareExceptionPreservesCode(): void + { + $this->assertSame(403, (new MiddlewareException('error', 403))->getCode()); + } + + public function testMiddlewareExceptionPreservesPrevious(): void + { + $previous = new \RuntimeException('inner error'); + $this->assertSame($previous, (new MiddlewareException('wrapper', 0, $previous))->getPrevious()); + } + + public function testMiddlewareExceptionCanBeCaughtAsSpinException(): void + { + $caught = null; + try { + throw new MiddlewareException('test'); + } catch (SpinException $e) { + $caught = $e; + } + $this->assertInstanceOf(MiddlewareException::class, $caught); + } +}