diff --git a/.ai/conventions.md b/.ai/conventions.md new file mode 100644 index 0000000..f797d60 --- /dev/null +++ b/.ai/conventions.md @@ -0,0 +1,32 @@ +# Neuron Framework - Coding Conventions + +## Code Style +- Tab indentation (not spaces) +- Opening braces on same line for class declarations, new line for methods/control structures +- Spaces before parentheses in function calls: `function_name ($arg)` +- Spaces around operators and after commas +- PHP closing tag `?>` used in source files +- PSR-4 autoloading under `Neuron\` namespace mapped to `src/Neuron/` + +## Input Validation Pattern +All user input goes through `Tools::checkInput()` for validation, then `Tools::getInput()` for retrieval: +1. `checkInput($value, $type)` returns bool - validates format +2. `getInput($data, $key, $type, $default)` returns validated+processed value or default + +Supported types: text, varchar, string, html, name, email, username, password, date, datetime, number, int, md5, base64, url, bool, raw + +## Database Queries +- Use `Query` class for parameterized queries +- Parameter types: `PARAM_STR`, `PARAM_NUMBER`, `PARAM_DATE`, `PARAM_POINT` +- Supports named parameters (`:name`) and positional (`?`) placeholders + +## Testing +- Tests extend `PHPUnit\Framework\TestCase` +- Test namespace: `Neuron\Tests` +- Database-dependent tests use `#[Group('database')]` attribute +- Run: `vendor/bin/phpunit --exclude-group=database` + +## Collections +- Base `Collection` class is observable (extends `Observable`) +- Triggers events: 'add', 'set', 'unset' +- Implements Iterator, ArrayAccess, Countable interfaces diff --git a/.ai/project-summary.md b/.ai/project-summary.md new file mode 100644 index 0000000..e4ded1e --- /dev/null +++ b/.ai/project-summary.md @@ -0,0 +1,44 @@ +# Neuron Framework - Project Summary + +## Overview +Neuron is a lightweight PHP framework by CatLab Interactive. It provides core utilities for web applications including input validation, database query building, collections, encryption, URL building, filtering, and localization. + +## Architecture + +### Core Components +- **Application** (`src/Neuron/Application.php`) - Main application dispatcher, singleton pattern +- **Config** (`src/Neuron/Config.php`) - Configuration loader with dot-notation access and environment overrides +- **Router** (`src/Neuron/Router.php`) - URL routing +- **URLBuilder** (`src/Neuron/URLBuilder.php`) - Static URL construction utilities + +### Input Handling +- **Tools** (`src/Neuron/Core/Tools.php`) - Input validation (`checkInput`) and retrieval (`getInput`) for types: text, varchar, string, html, name, email, username, password, date, datetime, number, int, md5, base64, url, bool, raw + +### Database +- **Query** (`src/Neuron/DB/Query.php`) - Parameterized SQL query builder with INSERT, UPDATE, DELETE, SELECT support +- **Database** (`src/Neuron/DB/Database.php`) - Database interface +- **MySQL** (`src/Neuron/DB/MySQL.php`) - MySQL implementation + +### Collections +- **Collection** (`src/Neuron/Collections/Collection.php`) - Observable collection implementing Iterator, ArrayAccess, Countable +- **ModelCollection** (`src/Neuron/Collections/ModelCollection.php`) - Model-specific collection with ID indexing +- **ErrorCollection** (`src/Neuron/Collections/ErrorCollection.php`) - Error message collection + +### Security +- **SimpleCrypt** (`src/Neuron/Encryption/SimpleCrypt.php`) - AES-256-CBC encryption/decryption +- **TokenGenerator** (`src/Neuron/Tools/TokenGenerator.php`) - Random token generation + +### Filtering +- **Filter Parser/Scanner** (`src/Neuron/Filter/`) - Expression-based filtering with context support + +## Testing +- Tests are in `tests/` directory using PHPUnit 10/11 +- Bootstrap in `tests/bootstrap.php` +- Database tests are grouped with `#[Group('database')]` and excluded from CI +- Run tests: `vendor/bin/phpunit --exclude-group=database` + +## Build & Dependencies +- PHP >= 8.1 +- Composer for dependency management +- Key dependencies: `nesbot/carbon`, `ext-gettext` +- Dev dependency: `phpunit/phpunit` diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..989f50a --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,36 @@ +name: Tests + +on: + push: + branches: [ master, main ] + pull_request: + branches: [ master, main ] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + php-version: ['8.1', '8.2', '8.3'] + + name: PHP ${{ matrix.php-version }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: gettext, openssl, mbstring + coverage: xdebug + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run tests + run: vendor/bin/phpunit --exclude-group=database diff --git a/.gitignore b/.gitignore index cdb3d20..9df1b6d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea/ vendor/ -composer.lock \ No newline at end of file +composer.lock +.phpunit.cache \ No newline at end of file diff --git a/claude.md b/claude.md new file mode 100644 index 0000000..ea6dd77 --- /dev/null +++ b/claude.md @@ -0,0 +1,43 @@ +# Claude AI Assistant Guide for Neuron Framework + +This document provides context for AI assistants working with the Neuron framework codebase. + +## Project Documentation + +- [Project Summary](.ai/project-summary.md) - Architecture overview and component descriptions +- [Coding Conventions](.ai/conventions.md) - Code style, patterns, and testing practices + +## Quick Start + +### Install Dependencies +```bash +composer install +``` + +### Run Tests +```bash +# Run all tests (excluding database-dependent tests) +vendor/bin/phpunit --exclude-group=database + +# Run specific test file +vendor/bin/phpunit tests/ToolsTest.php + +# Run with coverage +vendor/bin/phpunit --exclude-group=database --coverage-text +``` + +## Key Areas + +### Input Validation (`src/Neuron/Core/Tools.php`) +The primary input validation layer. All user input should be validated through `Tools::checkInput()` before use. The `Tools::getInput()` method combines validation with data retrieval and type-specific processing. + +**Important**: Date validation uses `ctype_digit()` and `checkdate()` to ensure all date parts are valid integers representing a real date. The `getInput()` method casts date parts to `(int)` before passing to `mktime()` as a defence-in-depth measure. + +### Database Queries (`src/Neuron/DB/Query.php`) +Always use parameterized queries via the `Query` class to prevent SQL injection. Never concatenate user input directly into SQL strings. + +### Testing +Tests are located in `tests/` and use PHPUnit. Database-dependent tests are grouped with `#[Group('database')]` and require a MySQL connection. CI runs tests excluding this group. + +## CI/CD +GitHub Actions workflow runs tests on PHP 8.1, 8.2, and 8.3. See `.github/workflows/tests.yml`. diff --git a/composer.json b/composer.json index 0afb7da..c2c4f85 100644 --- a/composer.json +++ b/composer.json @@ -15,18 +15,24 @@ ], "require" : { - "php": ">=5.5.0", + "php": ">=8.1.0", "ext-gettext" : "*", - "nesbot/carbon": "~1.18" + "nesbot/carbon": "^2.0||^3.0" }, "require-dev": { - "phpunit/phpunit": "5.1.*" + "phpunit/phpunit": "^10.0||^11.0" }, "autoload": { "psr-4" : { "Neuron\\" : "src/Neuron/" } + }, + + "autoload-dev": { + "psr-4": { + "Neuron\\Tests\\" : "tests/" + } } } diff --git a/phpunit.xml b/phpunit.xml index 7362ee9..216c033 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,11 +1,13 @@ - + - - + + src/Neuron - - + + diff --git a/src/Neuron/Core/Tools.php b/src/Neuron/Core/Tools.php index 4c88481..740af75 100755 --- a/src/Neuron/Core/Tools.php +++ b/src/Neuron/Core/Tools.php @@ -34,7 +34,7 @@ public static function getInput ($dat, $key, $type, $default = null) // For date's return timestamp. case 'date': $time = explode ('-', $dat[$key]); - return mktime (0, 0, 1, $time[1], $time[2], $time[0]); + return mktime (0, 0, 1, (int)$time[1], (int)$time[2], (int)$time[0]); case 'datetime': return new DateTime ($dat[$key]); @@ -106,8 +106,15 @@ public static function checkInput ($value, $type) elseif ($type == 'date') { - $time = explode ('-', $value); - return self::isValidUTF8 ($value) && (count ($time) == 3); + if (!self::isValidUTF8($value)) { + return false; + } + $time = explode('-', $value); + return count($time) === 3 + && ctype_digit($time[0]) + && ctype_digit($time[1]) + && ctype_digit($time[2]) + && checkdate((int)$time[1], (int)$time[2], (int)$time[0]); } elseif ($type == 'datetime') { diff --git a/src/Neuron/DB/Database.php b/src/Neuron/DB/Database.php index 1b3a470..91d02dd 100755 --- a/src/Neuron/DB/Database.php +++ b/src/Neuron/DB/Database.php @@ -54,11 +54,26 @@ public function setLogger (Logger $logger) $this->logger = $logger; } + /** @var Database|null Test-only override, set via setInstance() */ + private static $testInstance = null; + + /** + * Override the singleton instance for testing purposes. + * @param Database|null $instance + */ + public static function setInstance (?Database $instance) + { + self::$testInstance = $instance; + } + /** * @return Database */ public static function getInstance () { + if (self::$testInstance !== null) { + return self::$testInstance; + } return self::__getInstance (); } diff --git a/tests/CollectionTest.php b/tests/CollectionTest.php new file mode 100644 index 0000000..b13d453 --- /dev/null +++ b/tests/CollectionTest.php @@ -0,0 +1,195 @@ +assertCount (0, $collection); + + $collection->add ('a'); + $collection->add ('b'); + $collection->add ('c'); + $this->assertCount (3, $collection); + } + + public function testFirstAndLast () + { + $collection = new Collection (); + $collection->add ('first'); + $collection->add ('middle'); + $collection->add ('last'); + + $this->assertEquals ('first', $collection->first ()); + $this->assertEquals ('last', $collection->last ()); + } + + public function testFirstAndLastEmpty () + { + $collection = new Collection (); + $this->assertNull ($collection->first ()); + $this->assertNull ($collection->last ()); + } + + public function testIterator () + { + $collection = new Collection (); + $collection->add ('a'); + $collection->add ('b'); + $collection->add ('c'); + + $values = []; + foreach ($collection as $key => $value) { + $values[$key] = $value; + } + + $this->assertEquals ([0 => 'a', 1 => 'b', 2 => 'c'], $values); + } + + public function testRewind () + { + $collection = new Collection (); + $collection->add ('a'); + $collection->add ('b'); + + // Iterate to end + foreach ($collection as $v) {} + + // Rewind and verify + $collection->rewind (); + $this->assertEquals ('a', $collection->current ()); + } + + public function testArrayAccess () + { + $collection = new Collection (); + $collection[] = 'value1'; + $collection[] = 'value2'; + + $this->assertTrue (isset ($collection[0])); + $this->assertTrue (isset ($collection[1])); + $this->assertFalse (isset ($collection[2])); + + $this->assertEquals ('value1', $collection[0]); + $this->assertEquals ('value2', $collection[1]); + } + + public function testOffsetSet () + { + $collection = new Collection (); + $collection[5] = 'value5'; + + $this->assertTrue (isset ($collection[5])); + $this->assertEquals ('value5', $collection[5]); + } + + public function testOffsetUnset () + { + $collection = new Collection (); + $collection->add ('a'); + $collection->add ('b'); + + unset ($collection[0]); + $this->assertFalse (isset ($collection[0])); + } + + public function testRemove () + { + $collection = new Collection (); + $collection->add ('a'); + $collection->add ('b'); + $collection->add ('c'); + + $result = $collection->remove ('b'); + $this->assertTrue ($result); + $this->assertCount (2, $collection); + } + + public function testRemoveNonExistent () + { + $collection = new Collection (); + $collection->add ('a'); + + $result = $collection->remove ('nonexistent'); + $this->assertFalse ($result); + } + + public function testClear () + { + $collection = new Collection (); + $collection->add ('a'); + $collection->add ('b'); + + $collection->clear (); + $this->assertCount (0, $collection); + } + + public function testPeek () + { + $collection = new Collection (); + $collection->add ('a'); + $collection->add ('b'); + $collection->add ('c'); + + $collection->rewind (); + $this->assertEquals ('a', $collection->current ()); + $this->assertEquals ('b', $collection->peek ()); + // Position should not have changed + $this->assertEquals ('a', $collection->current ()); + } + + public function testPeekAtEnd () + { + $collection = new Collection (); + $collection->add ('a'); + + $collection->rewind (); + $this->assertNull ($collection->peek ()); + } + + public function testReverse () + { + $collection = new Collection (); + $collection->add ('a'); + $collection->add ('b'); + $collection->add ('c'); + + $collection->reverse (); + $this->assertEquals ('c', $collection->first ()); + $this->assertEquals ('a', $collection->last ()); + } + + public function testCurrentAtInvalidPosition () + { + $collection = new Collection (); + $this->assertNull ($collection->current ()); + } + + public function testValid () + { + $collection = new Collection (); + $collection->add ('a'); + + $collection->rewind (); + $this->assertTrue ($collection->valid ()); + $collection->next (); + $this->assertFalse ($collection->valid ()); + } + + public function testKey () + { + $collection = new Collection (); + $collection->add ('a'); + $collection->add ('b'); + + $collection->rewind (); + $this->assertEquals (0, $collection->key ()); + $collection->next (); + $this->assertEquals (1, $collection->key ()); + } +} diff --git a/tests/DbQueryInjectionTest.php b/tests/DbQueryInjectionTest.php new file mode 100644 index 0000000..a4a82a7 --- /dev/null +++ b/tests/DbQueryInjectionTest.php @@ -0,0 +1,862 @@ +bindValue (1, "O'Reilly", Query::PARAM_STR); +$sql = $query->getParsedQuery (); + +// Single quote must be escaped as \' +$this->assertStringContainsString ("\\'", $sql); +// The value is wrapped in outer single quotes +$this->assertStringContainsString ("name = '", $sql); +} + +public function testStringParamEscapesDoubleQuote () +{ +$query = new Query ("SELECT * FROM `users` WHERE name = ?"); +$query->bindValue (1, 'Say "hello"', Query::PARAM_STR); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ('\\"', $sql); +} + +public function testStringParamEscapesBackslash () +{ +$query = new Query ("SELECT * FROM `users` WHERE path = ?"); +$query->bindValue (1, 'C:\\Users\\test', Query::PARAM_STR); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ('\\\\', $sql); +} + +public function testStringParamEscapesNewline () +{ +$query = new Query ("SELECT * FROM `users` WHERE note = ?"); +$query->bindValue (1, "line1\nline2", Query::PARAM_STR); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ('\\n', $sql); +} + +public function testStringParamEscapesNullByte () +{ +$query = new Query ("SELECT * FROM `users` WHERE name = ?"); +$query->bindValue (1, "name\x00injected", Query::PARAM_STR); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ('\\0', $sql); +} + +public function testClassicOrInjectionInString () +{ +$query = new Query ("SELECT * FROM `users` WHERE username = ?"); +$query->bindValue (1, "' OR '1'='1", Query::PARAM_STR); +$sql = $query->getParsedQuery (); + +// The dangerous ' is escaped to \' preventing context break-out +$this->assertStringContainsString ("\\'", $sql); +// Verify the whole value is inside outer quotes +$this->assertStringContainsString ("username = '", $sql); +} + +public function testDropTableInjectionInString () +{ +$query = new Query ("INSERT INTO `log` SET message = ?"); +$query->bindValue (1, "'; DROP TABLE users;--", Query::PARAM_STR); +$sql = $query->getParsedQuery (); + +// The leading ' is escaped so the payload cannot break out of the string context +$this->assertStringContainsString ("\\'", $sql); +$this->assertStringContainsString ("message = '", $sql); +} + +public function testUnionSelectInjectionInString () +{ +$query = new Query ("SELECT * FROM `products` WHERE name = ?"); +$query->bindValue (1, "' UNION SELECT username, password FROM users--", Query::PARAM_STR); +$sql = $query->getParsedQuery (); + +// The single quote is escaped; the UNION cannot be executed as SQL +$this->assertStringContainsString ("\\'", $sql); +} + +public function testSleepInjectionInString () +{ +$query = new Query ("SELECT * FROM `users` WHERE id = ?"); +$query->bindValue (1, "1' AND SLEEP(5)--", Query::PARAM_STR); +$sql = $query->getParsedQuery (); + +// The single quote is escaped +$this->assertStringContainsString ("\\'", $sql); +$this->assertStringContainsString ("id = '", $sql); +} + +public function testStackedQueryInjection () +{ +$query = new Query ("SELECT * FROM `users` WHERE name = ?"); +$query->bindValue (1, "admin'; SELECT * FROM secrets;--", Query::PARAM_STR); +$sql = $query->getParsedQuery (); + +// Quote escaped; second statement cannot execute +$this->assertStringContainsString ("\\'", $sql); +} + +public function testCommentBasedInjectionInString () +{ +$query = new Query ("SELECT * FROM `users` WHERE name = ?"); +$query->bindValue (1, "admin'--", Query::PARAM_STR); +$sql = $query->getParsedQuery (); + +// Single quote must be escaped +$this->assertStringContainsString ("admin\\'--", $sql); +} + +public function testBlindInjectionInString () +{ +$query = new Query ("SELECT * FROM `users` WHERE name = ?"); +$query->bindValue (1, "' AND 1=1--", Query::PARAM_STR); +$sql = $query->getParsedQuery (); + +// Single quote escaped +$this->assertStringContainsString ("\\'", $sql); +} + +public function testNumericValueInStringParam () +{ +$query = new Query ("SELECT * FROM `t` WHERE col = ?"); +$query->bindValue (1, 42, Query::PARAM_STR); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("'42'", $sql); +} + +public function testFloatValueInStringParam () +{ +$query = new Query ("SELECT * FROM `t` WHERE col = ?"); +$query->bindValue (1, 3.14, Query::PARAM_STR); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("'3.14'", $sql); +} + +// --------------------------------------------------------------- +// PARAM_UNKNOWN — automatic type detection +// --------------------------------------------------------------- + +public function testUnknownParamIntIsNotQuoted () +{ +$query = new Query ("SELECT * FROM `t` WHERE id = ?"); +$query->bindValue (1, 5, Query::PARAM_UNKNOWN); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("id = 5", $sql); +$this->assertStringNotContainsString ("id = '5'", $sql); +} + +public function testUnknownParamStringInjection () +{ +$query = new Query ("SELECT * FROM `t` WHERE name = ?"); +$query->bindValue (1, "' OR '1'='1", Query::PARAM_UNKNOWN); +$sql = $query->getParsedQuery (); + +// Single quote escaped +$this->assertStringContainsString ("\\'", $sql); +} + +public function testUnknownParamCommaStringNotModified () +{ +// Comma-format "3,14" is not is_numeric(), treated as string +$query = new Query ("SELECT * FROM `t` WHERE val = ?"); +$query->bindValue (1, "3,14", Query::PARAM_UNKNOWN); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("'3,14'", $sql); +} + +// --------------------------------------------------------------- +// PARAM_NUMBER — numeric parameter protection +// --------------------------------------------------------------- + +public function testNumberParamValidInt () +{ +$query = new Query ("SELECT * FROM `t` WHERE id = ?"); +$query->bindValue (1, 42, Query::PARAM_NUMBER); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("id = 42", $sql); +} + +public function testNumberParamValidFloat () +{ +$query = new Query ("SELECT * FROM `t` WHERE price = ?"); +$query->bindValue (1, 9.99, Query::PARAM_NUMBER); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("price = 9.99", $sql); +} + +public function testNumberParamThrowsOnNonNumericString () +{ +$this->expectException (InvalidParameter::class); + +$query = new Query ("SELECT * FROM `t` WHERE id = ?"); +$query->bindValue (1, "'; DROP TABLE users;--", Query::PARAM_NUMBER); +$query->getParsedQuery (); +} + +public function testNumberParamThrowsOnInjectionString () +{ +$this->expectException (InvalidParameter::class); + +$query = new Query ("SELECT * FROM `t` WHERE id = ?"); +$query->bindValue (1, "1 OR 1=1", Query::PARAM_NUMBER); +$query->getParsedQuery (); +} + +public function testNumberParamThrowsOnUnionSelect () +{ +$this->expectException (InvalidParameter::class); + +$query = new Query ("SELECT * FROM `t` WHERE id = ?"); +$query->bindValue (1, "1 UNION SELECT password FROM users", Query::PARAM_NUMBER); +$query->getParsedQuery (); +} + +public function testNumberParamThrowsOnAlphaString () +{ +$this->expectException (InvalidParameter::class); + +$query = new Query ("SELECT * FROM `t` WHERE id = ?"); +$query->bindValue (1, "admin", Query::PARAM_NUMBER); +$query->getParsedQuery (); +} + +// --------------------------------------------------------------- +// PARAM_DATE +// --------------------------------------------------------------- + +public function testDateParamTimestamp () +{ +$ts = gmmktime (0, 0, 0, 6, 15, 2020); + +$query = new Query ("SELECT * FROM `t` WHERE created = ?"); +$query->bindValue (1, $ts, Query::PARAM_DATE); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("FROM_UNIXTIME($ts)", $sql); +} + +public function testDateParamDateTimeObject () +{ +$dt = new DateTime ('2020-06-15 12:30:00'); + +$query = new Query ("SELECT * FROM `t` WHERE created = ?"); +$query->bindValue (1, $dt, Query::PARAM_DATE); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("'2020-06-15 12:30:00'", $sql); +} + +public function testDateParamThrowsOnInjectionString () +{ +$this->expectException (InvalidParameter::class); + +$query = new Query ("SELECT * FROM `t` WHERE created = ?"); +$query->bindValue (1, "' OR '1'='1", Query::PARAM_DATE); +$query->getParsedQuery (); +} + +public function testDateParamThrowsOnNonNumericString () +{ +$this->expectException (InvalidParameter::class); + +$query = new Query ("SELECT * FROM `t` WHERE created = ?"); +$query->bindValue (1, "not-a-date", Query::PARAM_DATE); +$query->getParsedQuery (); +} + +// --------------------------------------------------------------- +// PARAM_POINT +// --------------------------------------------------------------- + +public function testPointParam () +{ +$point = new Point (4.3517, 50.8503); + +$query = new Query ("INSERT INTO `locations` SET pos = ?"); +$query->bindValue (1, $point, Query::PARAM_POINT); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("POINT(4.3517,50.8503)", $sql); +} + +public function testPointParamThrowsOnNonPoint () +{ +$this->expectException (InvalidParameter::class); + +$query = new Query ("INSERT INTO `locations` SET pos = ?"); +$query->bindValue (1, "POINT(0,0) INJECTION", Query::PARAM_POINT); +$query->getParsedQuery (); +} + +public function testPointConstructorRejectsNonNumeric () +{ +$this->expectException (InvalidParameter::class); +new Point ("x", "y"); +} + +// --------------------------------------------------------------- +// NULL handling +// --------------------------------------------------------------- + +public function testNullValueWithCanBeNullTrue () +{ +// null value + canBeNull=true → should produce NULL in SQL +$query = new Query ("UPDATE `t` SET col = ?"); +$query->bindValue (1, null, Query::PARAM_STR, true); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("col = NULL", $sql); +} + +public function testNullValueWithCanBeNullFalse () +{ +// null value + canBeNull=false (default) → treated as empty string +$query = new Query ("UPDATE `t` SET col = ?"); +$query->bindValue (1, null, Query::PARAM_STR, false); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("col = ''", $sql); +} + +public function testNullInWhereProducesIsNull () +{ +$query = Query::select ('users', array ('id'), array ('deleted_at' => null)); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("deleted_at IS NULL", $sql); +} + +public function testNullViaStaticBuilderProducesNull () +{ +// Via static builder, no explicit type → $v[2] not set → defaults to true +// in getParsedQuery → null produces NULL +$query = Query::insert ('t', array ('col' => null)); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("col = NULL", $sql); +} + +// --------------------------------------------------------------- +// Auto-detection of DateTime and Point (via bindValues without type) +// --------------------------------------------------------------- + +public function testAutoDetectDateTimeViaBindValues () +{ +$dt = new DateTime ('2023-01-15 08:00:00'); + +$query = new Query ("INSERT INTO `t` SET created = ?"); +$query->bindValues (array (array ($dt))); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("'2023-01-15 08:00:00'", $sql); +} + +public function testAutoDetectPointViaBindValues () +{ +$point = new Point (10.5, 20.3); + +$query = new Query ("INSERT INTO `t` SET pos = ?"); +$query->bindValues (array (array ($point))); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("POINT(10.5,20.3)", $sql); +} + +// --------------------------------------------------------------- +// Array (IN clause) values +// --------------------------------------------------------------- + +public function testArrayValueForInClause () +{ +$query = Query::select ('users', array ('id', 'name'), array ( +'id' => array (array (1, 2, 3), Query::PARAM_NUMBER), +)); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("(1,2,3)", $sql); +} + +public function testArrayStringValuesEscaped () +{ +$query = Query::select ('users', array ('name'), array ( +'name' => array (array ("admin", "' OR '1'='1"), Query::PARAM_STR), +)); +$sql = $query->getParsedQuery (); + +// The dangerous single quote in the injection payload must be escaped +$this->assertStringContainsString ("\\'", $sql); +} + +// --------------------------------------------------------------- +// WHERE comparators +// --------------------------------------------------------------- + +public function testWhereNotEqualsPrefix () +{ +$query = Query::select ('t', array (), array ( +'status' => array ('!active', Query::PARAM_STR), +)); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("status != ", $sql); +$this->assertStringContainsString ("'active'", $sql); +} + +public function testWhereLike () +{ +$query = Query::select ('t', array (), array ( +'name' => array ('%test%', Query::PARAM_STR, 'LIKE'), +)); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("name LIKE ", $sql); +$this->assertStringContainsString ("'%test%'", $sql); +} + +public function testWhereNot () +{ +$query = Query::select ('t', array (), array ( +'type' => array ('admin', Query::PARAM_STR, 'NOT'), +)); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("type != ", $sql); +} + +public function testWhereGreaterThan () +{ +$query = Query::select ('t', array (), array ( +'age' => array (18, Query::PARAM_NUMBER, '>'), +)); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("age > ", $sql); +$this->assertStringContainsString ("18", $sql); +} + +public function testWhereLessThan () +{ +$query = Query::select ('t', array (), array ( +'age' => array (65, Query::PARAM_NUMBER, '<'), +)); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("age < 65", $sql); +} + +public function testWhereGreaterOrEqual () +{ +$query = Query::select ('t', array (), array ( +'score' => array (100, Query::PARAM_NUMBER, '>='), +)); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("score >= 100", $sql); +} + +public function testWhereLessOrEqual () +{ +$query = Query::select ('t', array (), array ( +'score' => array (50, Query::PARAM_NUMBER, '<='), +)); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("score <= 50", $sql); +} + +public function testWhereNotEqualsOperator () +{ +$query = Query::select ('t', array (), array ( +'status' => array (0, Query::PARAM_NUMBER, '!='), +)); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("status != 0", $sql); +} + +public function testWhereInOperator () +{ +$query = Query::select ('t', array (), array ( +'id' => array (array (1, 2, 3), Query::PARAM_NUMBER, 'IN'), +)); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("id IN ", $sql); +$this->assertStringContainsString ("(1,2,3)", $sql); +} + +public function testWhereArrayImplicitIn () +{ +$query = Query::select ('t', array (), array ( +'id' => array (array (5, 10, 15), Query::PARAM_NUMBER), +)); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("id IN ", $sql); +} + +// --------------------------------------------------------------- +// SELECT builder +// --------------------------------------------------------------- + +public function testSelectAllColumns () +{ +$query = Query::select ('users', array (), array ('active' => array (1, Query::PARAM_NUMBER))); +$sql = $query->getParsedQuery (); + +$this->assertStringStartsWith ("SELECT *", $sql); +$this->assertStringContainsString ("FROM `users`", $sql); +$this->assertStringContainsString ("WHERE active = 1", $sql); +} + +public function testSelectSpecificColumns () +{ +$query = Query::select ('users', array ('id', 'email'), array ()); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("SELECT id, email", $sql); +$this->assertStringContainsString ("FROM `users`", $sql); +} + +public function testSelectNoWhere () +{ +$query = Query::select ('users', array ('id')); +$sql = $query->getParsedQuery (); + +$this->assertStringNotContainsString ("WHERE", $sql); +} + +public function testSelectWithOrder () +{ +$query = Query::select ('users', array ('id'), array (), array ('name ASC', 'created DESC')); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("ORDER BY name ASC, created DESC", $sql); +} + +public function testSelectWithLimit () +{ +$query = Query::select ('users', array ('id'), array (), array (), '10, 20'); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("LIMIT 10, 20", $sql); +} + +public function testSelectWithOrderAndLimit () +{ +$query = Query::select ('users', array ('id'), array (), array ('id ASC'), '0, 5'); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("ORDER BY id ASC", $sql); +$this->assertStringContainsString ("LIMIT 0, 5", $sql); +} + +// --------------------------------------------------------------- +// INSERT builder +// --------------------------------------------------------------- + +public function testInsertBasic () +{ +$query = Query::insert ('users', array ( +'name' => 'Alice', +'age' => array (30, Query::PARAM_NUMBER), +)); +$sql = $query->getParsedQuery (); + +$this->assertStringStartsWith ("INSERT INTO `users`", $sql); +$this->assertStringContainsString ("name = 'Alice'", $sql); +$this->assertStringContainsString ("age = 30", $sql); +} + +public function testInsertEscapesSingleQuote () +{ +$query = Query::insert ('users', array ('name' => "O'Brien")); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("\\'", $sql); +} + +public function testInsertWithInjectionPayload () +{ +$query = Query::insert ('log', array ('msg' => "'; DROP TABLE users;--")); +$sql = $query->getParsedQuery (); + +// Single quote is escaped — the payload cannot break out of the string context +$this->assertStringContainsString ("\\'", $sql); +$this->assertStringContainsString ("msg = '", $sql); +} + +public function testInsertWithNullValue () +{ +$query = Query::insert ('users', array ('id' => 1, 'bio' => null)); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("bio = NULL", $sql); +} + +// --------------------------------------------------------------- +// REPLACE builder +// --------------------------------------------------------------- + +public function testReplaceBasic () +{ +$query = Query::replace ('users', array ( +'id' => array (1, Query::PARAM_NUMBER), +'name' => 'Bob', +)); +$sql = $query->getParsedQuery (); + +$this->assertStringStartsWith ("REPLACE INTO `users`", $sql); +$this->assertStringContainsString ("id = 1", $sql); +$this->assertStringContainsString ("name = 'Bob'", $sql); +} + +public function testReplaceWithInjectionPayload () +{ +$query = Query::replace ('users', array ('name' => "'; DROP TABLE users;--")); +$sql = $query->getParsedQuery (); + +// Single quote is escaped +$this->assertStringContainsString ("\\'", $sql); +} + +// --------------------------------------------------------------- +// UPDATE builder +// --------------------------------------------------------------- + +public function testUpdateBasic () +{ +$query = Query::update ( +'users', +array ('name' => 'Alice'), +array ('id' => array (1, Query::PARAM_NUMBER)) +); +$sql = $query->getParsedQuery (); + +$this->assertStringStartsWith ("UPDATE `users`", $sql); +$this->assertStringContainsString ("SET name = 'Alice'", $sql); +$this->assertStringContainsString ("WHERE id = 1", $sql); +} + +public function testUpdateWithInjectionInSet () +{ +$query = Query::update ( +'users', +array ('bio' => "'; DROP TABLE secrets;--"), +array ('id' => array (42, Query::PARAM_NUMBER)) +); +$sql = $query->getParsedQuery (); + +// Single quote escaped in the SET clause +$this->assertStringContainsString ("\\'", $sql); +} + +public function testUpdateWithInjectionInWhere () +{ +$query = Query::update ( +'users', +array ('bio' => 'safe value'), +array ('name' => "' OR '1'='1") +); +$sql = $query->getParsedQuery (); + +// Single quote in WHERE value must be escaped +$this->assertStringContainsString ("\\'", $sql); +} + +// --------------------------------------------------------------- +// DELETE builder +// --------------------------------------------------------------- + +public function testDeleteBasic () +{ +$query = Query::delete ('users', array ('id' => array (5, Query::PARAM_NUMBER))); +$sql = $query->getParsedQuery (); + +$this->assertStringStartsWith ("DELETE FROM `users`", $sql); +$this->assertStringContainsString ("WHERE id = 5", $sql); +} + +public function testDeleteWithInjectionInWhere () +{ +$query = Query::delete ('users', array ('name' => "' OR '1'='1")); +$sql = $query->getParsedQuery (); + +// Single quote escaped +$this->assertStringContainsString ("\\'", $sql); +} + +public function testDeleteNoWhere () +{ +$query = Query::delete ('t', array ()); +$sql = $query->getParsedQuery (); + +$this->assertStringStartsWith ("DELETE FROM `t`", $sql); +$this->assertStringNotContainsString ("WHERE", $sql); +} + +// --------------------------------------------------------------- +// Named parameters injection +// --------------------------------------------------------------- + +public function testNamedParamInjection () +{ +$query = new Query ("SELECT * FROM `users` WHERE name = :name AND role = :role"); +$query->bindValue ('name', "' OR '1'='1"); +$query->bindValue ('role', 'admin'); +$sql = $query->getParsedQuery (); + +// Single quote in named param must be escaped +$this->assertStringContainsString ("\\'", $sql); +$this->assertStringContainsString ("role = 'admin'", $sql); +} + +public function testNamedParamDoesNotReplaceItselfInValue () +{ +$query = new Query ("INSERT INTO `t` SET a = :a, b = :b"); +$query->bindValue ('a', 'value with :b placeholder'); +$query->bindValue ('b', 'real b'); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("a = 'value with :b placeholder'", $sql); +$this->assertStringContainsString ("b = 'real b'", $sql); +} + +// --------------------------------------------------------------- +// Positional parameters with injection +// --------------------------------------------------------------- + +public function testPositionalParamsWithInjection () +{ +$query = new Query ("SELECT * FROM `t` WHERE a = ? AND b = ?"); +$query->bindValue (1, "' OR 1=1--"); +$query->bindValue (2, "'; DROP TABLE t;--"); +$sql = $query->getParsedQuery (); + +// Both single quotes must be escaped +$this->assertStringContainsString ("\\'", $sql); +} + +public function testQuestionMarkInValueDoesNotBreakParsing () +{ +$query = new Query ("INSERT INTO `t` SET msg = ?, other = ?"); +$query->bindValue (1, "Is this a question?"); +$query->bindValue (2, "yes"); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("msg = 'Is this a question?'", $sql); +$this->assertStringContainsString ("other = 'yes'", $sql); +} + +// --------------------------------------------------------------- +// bindValue chaining +// --------------------------------------------------------------- + +public function testBindValueChaining () +{ +$query = (new Query ("SELECT * FROM `t` WHERE a = ? AND b = ?")) +->bindValue (1, 'foo') +->bindValue (2, 'bar'); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ("a = 'foo'", $sql); +$this->assertStringContainsString ("b = 'bar'", $sql); +} + +// --------------------------------------------------------------- +// Table name escaping +// --------------------------------------------------------------- + +public function testTableNameIsBacktickEscaped () +{ +$query = Query::select ('my_table', array ('id')); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ('`my_table`', $sql); +} + +public function testInsertTableNameIsBacktickEscaped () +{ +$query = Query::insert ('log_entries', array ('msg' => 'test')); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ('`log_entries`', $sql); +} + +// --------------------------------------------------------------- +// Edge cases with special characters +// --------------------------------------------------------------- + +public function testCarriageReturnEscaped () +{ +$query = new Query ("INSERT INTO `t` SET data = ?"); +$query->bindValue (1, "line1\rline2", Query::PARAM_STR); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ('\\r', $sql); +} + +public function testSubstituteCharacterEscaped () +{ +$query = new Query ("INSERT INTO `t` SET data = ?"); +$query->bindValue (1, "data\x1amore", Query::PARAM_STR); +$sql = $query->getParsedQuery (); + +$this->assertStringContainsString ('\\Z', $sql); +} + +public function testMultipleInjectionVectorsInSingleQuery () +{ +$query = Query::insert ('audit_log', array ( +'user' => "' OR '1'='1", +'action' => "'; DROP TABLE audit_log;--", +'payload' => "' UNION SELECT password FROM users--", +)); +$sql = $query->getParsedQuery (); + +// All dangerous single quotes must be escaped +$this->assertGreaterThanOrEqual (3, substr_count ($sql, "\\'")); +} +} diff --git a/tests/DbQueryTest.php b/tests/DbQueryTest.php index f145e6c..8a924ae 100644 --- a/tests/DbQueryTest.php +++ b/tests/DbQueryTest.php @@ -2,14 +2,16 @@ namespace Neuron\Tests; -use PHPUnit_Framework_TestCase; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\Group; use Neuron\DB\Query; /** * Class DbQueryTest * @package Neuron\Tests */ -class DbQueryTest extends PHPUnit_Framework_TestCase +#[Group('database')] +class DbQueryTest extends TestCase { /** * @test diff --git a/tests/ErrorCollectionTest.php b/tests/ErrorCollectionTest.php new file mode 100644 index 0000000..5b8786e --- /dev/null +++ b/tests/ErrorCollectionTest.php @@ -0,0 +1,55 @@ +addError ('Something went wrong'); + + $this->assertCount (1, $collection); + $this->assertInstanceOf (\Neuron\Models\Error::class, $error); + } + + public function testGetData () + { + $collection = new ErrorCollection (); + $collection->addError ('Error %s', ['one']); + $collection->addError ('Error %s', ['two']); + + $data = $collection->getData (); + $this->assertCount (2, $data); + $this->assertEquals ('Error one', $data[0]); + $this->assertEquals ('Error two', $data[1]); + } + + public function testGetDetailedData () + { + $collection = new ErrorCollection (); + $error = $collection->addError ('Error %s in %s', ['field', 'form']); + $error->setSubject ('test_subject'); + $error->setCode ('ERR001'); + + $detailed = $collection->getDetailedData (); + $this->assertCount (1, $detailed); + $this->assertEquals ('Error field in form', $detailed[0]['message']); + $this->assertEquals ('Error %s in %s', $detailed[0]['template']); + $this->assertEquals (['field', 'form'], $detailed[0]['arguments']); + $this->assertEquals ('test_subject', $detailed[0]['subject']); + $this->assertEquals ('ERR001', $detailed[0]['code']); + } + + public function testAddErrorWithNoArguments () + { + $collection = new ErrorCollection (); + $collection->addError ('Simple error message'); + + $data = $collection->getData (); + $this->assertEquals ('Simple error message', $data[0]); + } +} diff --git a/tests/FilterTest.php b/tests/FilterTest.php index e79f8f6..6fd1993 100644 --- a/tests/FilterTest.php +++ b/tests/FilterTest.php @@ -10,13 +10,13 @@ use Neuron\Filter\Context; use Neuron\Filter\Field; -use PHPUnit_Framework_TestCase; +use PHPUnit\Framework\TestCase; use Neuron\Filter\Parser; use Neuron\Filter\Scanner; class FilterTest - extends PHPUnit_Framework_TestCase + extends TestCase { public function testFilter () diff --git a/tests/SimpleCryptTest.php b/tests/SimpleCryptTest.php new file mode 100644 index 0000000..34851bc --- /dev/null +++ b/tests/SimpleCryptTest.php @@ -0,0 +1,81 @@ +encrypt ($original); + $this->assertNotEquals ($original, $encrypted); + + $decrypted = $crypt->decrypt ($encrypted); + $this->assertEquals ($original, $decrypted); + } + + public function testDifferentPasswordsFail () + { + $crypt1 = new SimpleCrypt ('password1'); + $crypt2 = new SimpleCrypt ('password2'); + + $encrypted = $crypt1->encrypt ('secret'); + $decrypted = $crypt2->decrypt ($encrypted); + + $this->assertNotEquals ('secret', $decrypted); + } + + public function testEncryptProducesDifferentOutput () + { + $crypt = new SimpleCrypt ('password'); + + $encrypted1 = $crypt->encrypt ('same text'); + $encrypted2 = $crypt->encrypt ('same text'); + + // Due to random salt, encrypted values should differ + $this->assertNotEquals ($encrypted1, $encrypted2); + } + + public function testEncryptDecryptEmptyString () + { + $crypt = new SimpleCrypt ('password'); + $encrypted = $crypt->encrypt (''); + $decrypted = $crypt->decrypt ($encrypted); + $this->assertEquals ('', $decrypted); + } + + public function testEncryptDecryptSpecialCharacters () + { + $crypt = new SimpleCrypt ('password'); + $original = "Special chars: !@#\$%^&*()_+-=[]{}|;':\",./<>?"; + + $encrypted = $crypt->encrypt ($original); + $decrypted = $crypt->decrypt ($encrypted); + $this->assertEquals ($original, $decrypted); + } + + public function testEncryptDecryptUTF8 () + { + $crypt = new SimpleCrypt ('password'); + $original = 'Héllo Wörld 日本語'; + + $encrypted = $crypt->encrypt ($original); + $decrypted = $crypt->decrypt ($encrypted); + $this->assertEquals ($original, $decrypted); + } + + public function testEncryptDecryptWithSaltMarkerInContent () + { + $crypt = new SimpleCrypt ('password'); + $original = 'Text with |||CWSALT inside it'; + + $encrypted = $crypt->encrypt ($original); + $decrypted = $crypt->decrypt ($encrypted); + $this->assertEquals ($original, $decrypted); + } +} diff --git a/tests/TestDatabase.php b/tests/TestDatabase.php new file mode 100644 index 0000000..12d1b70 --- /dev/null +++ b/tests/TestDatabase.php @@ -0,0 +1,49 @@ + "\\\\", + "\x00" => "\\0", + "\n" => "\\n", + "\r" => "\\r", + "'" => "\\'", + '"' => '\\"', + "\x1a" => "\\Z", + ]); + } + + public function query ($sSQL): int + { + return 0; + } + + public function multiQuery ($sSQL): int + { + return 0; + } + + public function fromUnixtime ($timestamp): string + { + return date ('Y-m-d H:i:s', $timestamp); + } + + public function toUnixtime ($date): int + { + return strtotime ($date); + } +} diff --git a/tests/TokenGeneratorTest.php b/tests/TokenGeneratorTest.php index 2f301e9..a2f1693 100644 --- a/tests/TokenGeneratorTest.php +++ b/tests/TokenGeneratorTest.php @@ -3,11 +3,11 @@ namespace Neuron\Tests; -use PHPUnit_Framework_TestCase; +use PHPUnit\Framework\TestCase; class TokenGeneratorTest - extends PHPUnit_Framework_TestCase + extends TestCase { public function testLengthSimplified () { diff --git a/tests/ToolsTest.php b/tests/ToolsTest.php index 26cae51..6c92292 100644 --- a/tests/ToolsTest.php +++ b/tests/ToolsTest.php @@ -3,12 +3,12 @@ namespace Neuron\Tests; -use PHPUnit_Framework_TestCase; +use PHPUnit\Framework\TestCase; use Neuron\Core\Tools; class ToolsTest - extends PHPUnit_Framework_TestCase + extends TestCase { public function testEmailInputCheck () { @@ -102,4 +102,487 @@ public function testDateInput () { $this->assertTrue (Tools::checkInput ('2015-06-01T10:00', 'datetime')); $this->assertFalse (Tools::checkInput ('06-01-2015T10:00', 'datetime')); } + + // --------------------------------------------------------------- + // Date validation tests (the bug fix) + // --------------------------------------------------------------- + + public function testDateCheckInputValidDates () + { + $this->assertTrue (Tools::checkInput ('2015-06-01', 'date')); + $this->assertTrue (Tools::checkInput ('2000-01-01', 'date')); + $this->assertTrue (Tools::checkInput ('1999-12-31', 'date')); + $this->assertTrue (Tools::checkInput ('2024-02-29', 'date')); // leap year + } + + public function testDateCheckInputInvalidNonIntegerParts () + { + // The original bug: "a-b-c" would pass + $this->assertFalse (Tools::checkInput ('a-b-c', 'date')); + $this->assertFalse (Tools::checkInput ('foo-bar-baz', 'date')); + $this->assertFalse (Tools::checkInput ('20xx-01-01', 'date')); + $this->assertFalse (Tools::checkInput ('2015-ab-01', 'date')); + $this->assertFalse (Tools::checkInput ('2015-01-cd', 'date')); + } + + public function testDateCheckInputInvalidDateValues () + { + $this->assertFalse (Tools::checkInput ('2015-13-01', 'date')); // month 13 + $this->assertFalse (Tools::checkInput ('2015-00-01', 'date')); // month 0 + $this->assertFalse (Tools::checkInput ('2015-02-30', 'date')); // Feb 30 + $this->assertFalse (Tools::checkInput ('2023-02-29', 'date')); // non-leap year + $this->assertFalse (Tools::checkInput ('2015-06-32', 'date')); // day 32 + $this->assertFalse (Tools::checkInput ('0000-01-01', 'date')); // year 0 + } + + public function testDateCheckInputInvalidFormats () + { + $this->assertFalse (Tools::checkInput ('', 'date')); + $this->assertFalse (Tools::checkInput ('2015', 'date')); + $this->assertFalse (Tools::checkInput ('2015-06', 'date')); + $this->assertFalse (Tools::checkInput ('2015/06/01', 'date')); + $this->assertFalse (Tools::checkInput ('01-06-2015', 'date')); // wrong order but valid checkdate would pass + $this->assertFalse (Tools::checkInput ('2015-06-01-extra', 'date')); + } + + public function testDateGetInputValidDate () + { + $dat = array ('date' => '2015-06-01'); + $result = Tools::getInput ($dat, 'date', 'date'); + $this->assertIsInt ($result); + $this->assertEquals ('2015-06-01', date ('Y-m-d', $result)); + } + + public function testDateGetInputInvalidDate () + { + $dat = array ('date' => 'a-b-c'); + $result = Tools::getInput ($dat, 'date', 'date'); + $this->assertNull ($result); + } + + public function testDateGetInputMissing () + { + $dat = array (); + $result = Tools::getInput ($dat, 'date', 'date'); + $this->assertNull ($result); + } + + public function testDateGetInputDefault () + { + $dat = array ('date' => 'invalid'); + $result = Tools::getInput ($dat, 'date', 'date', 'default_value'); + $this->assertEquals ('default_value', $result); + } + + // --------------------------------------------------------------- + // Datetime validation tests + // --------------------------------------------------------------- + + public function testDatetimeCheckInputValid () + { + $this->assertTrue (Tools::checkInput ('2015-06-01T10:00', 'datetime')); + $this->assertTrue (Tools::checkInput ('2024-12-31T23:59', 'datetime')); + } + + public function testDatetimeCheckInputInvalid () + { + $this->assertFalse (Tools::checkInput ('06-01-2015T10:00', 'datetime')); + $this->assertFalse (Tools::checkInput ('not-a-datetime', 'datetime')); + $this->assertFalse (Tools::checkInput ('2015-06-01 10:00', 'datetime')); + $this->assertFalse (Tools::checkInput ('', 'datetime')); + } + + public function testDatetimeGetInputValid () + { + $dat = array ('dt' => '2015-06-01T10:00'); + $result = Tools::getInput ($dat, 'dt', 'datetime'); + $this->assertInstanceOf (\DateTime::class, $result); + } + + // --------------------------------------------------------------- + // Text and varchar type tests + // --------------------------------------------------------------- + + public function testTextCheckInput () + { + $this->assertTrue (Tools::checkInput ('anything', 'text')); + $this->assertTrue (Tools::checkInput ('', 'text')); + $this->assertTrue (Tools::checkInput ('', 'text')); + } + + public function testVarcharCheckInput () + { + $this->assertTrue (Tools::checkInput ('valid text', 'varchar')); + $this->assertTrue (Tools::checkInput ('valid text', 'string')); + $this->assertTrue (Tools::checkInput ('valid html', 'html')); + } + + public function testNameCheckInput () + { + $this->assertTrue (Tools::checkInput ('John Doe', 'name')); + $this->assertFalse (Tools::checkInput ('John', 'name')); + $this->assertFalse (Tools::checkInput ('', 'name')); + } + + // --------------------------------------------------------------- + // Bool type tests + // --------------------------------------------------------------- + + public function testBoolCheckInput () + { + $this->assertTrue (Tools::checkInput (1, 'bool')); + $this->assertTrue (Tools::checkInput ('true', 'bool')); + $this->assertFalse (Tools::checkInput (0, 'bool')); + $this->assertFalse (Tools::checkInput ('false', 'bool')); + $this->assertFalse (Tools::checkInput ('', 'bool')); + } + + public function testBoolGetInput () + { + // When checkInput passes (value is 1 or 'true'), getInput returns strip_tags(value) + $dat = array ('flag' => 1); + $this->assertEquals ('1', Tools::getInput ($dat, 'flag', 'bool')); + + $dat = array ('flag' => 'true'); + $this->assertEquals ('true', Tools::getInput ($dat, 'flag', 'bool')); + + // When bool validation fails, getInput returns false (special case) + $dat = array ('flag' => 0); + $this->assertFalse (Tools::getInput ($dat, 'flag', 'bool')); + + $dat = array ('flag' => 'no'); + $this->assertFalse (Tools::getInput ($dat, 'flag', 'bool')); + } + + // --------------------------------------------------------------- + // Password type tests + // --------------------------------------------------------------- + + public function testPasswordCheckInput () + { + $this->assertTrue (Tools::checkInput ('password123', 'password')); + $this->assertTrue (Tools::checkInput ('12345678', 'password')); + $this->assertFalse (Tools::checkInput ('short', 'password')); + $this->assertFalse (Tools::checkInput ('1234567', 'password')); // 7 chars + $this->assertFalse (Tools::checkInput (str_repeat ('a', 257), 'password')); // too long + $this->assertTrue (Tools::checkInput (str_repeat ('a', 256), 'password')); // max allowed + } + + // --------------------------------------------------------------- + // Username type tests + // --------------------------------------------------------------- + + public function testUsernameCheckInput () + { + $this->assertTrue (Tools::checkInput ('john_doe', 'username')); + $this->assertTrue (Tools::checkInput ('User123', 'username')); + $this->assertTrue (Tools::checkInput ('abc', 'username')); // min 3 chars + $this->assertFalse (Tools::checkInput ('ab', 'username')); // too short + $this->assertFalse (Tools::checkInput ('', 'username')); + $this->assertFalse (Tools::checkInput ('user name', 'username')); // space + $this->assertFalse (Tools::checkInput ('user@name', 'username')); // special chars + $this->assertFalse (Tools::checkInput (str_repeat ('a', 21), 'username')); // too long + } + + // --------------------------------------------------------------- + // MD5 type tests + // --------------------------------------------------------------- + + public function testMd5CheckInput () + { + $this->assertTrue (Tools::checkInput (md5 ('test'), 'md5')); + $this->assertTrue (Tools::checkInput ('d41d8cd98f00b204e9800998ecf8427e', 'md5')); + $this->assertFalse (Tools::checkInput ('tooshort', 'md5')); + $this->assertFalse (Tools::checkInput ('', 'md5')); + $this->assertFalse (Tools::checkInput (str_repeat ('a', 33), 'md5')); + } + + // --------------------------------------------------------------- + // Base64 type tests + // --------------------------------------------------------------- + + public function testBase64CheckInput () + { + $this->assertTrue (Tools::checkInput (base64_encode ('test'), 'base64')); + $this->assertTrue (Tools::checkInput (base64_encode ('hello world'), 'base64')); + $this->assertFalse (Tools::checkInput ('not valid base64!!!', 'base64')); + } + + public function testBase64GetInput () + { + $dat = array ('data' => base64_encode ('hello world')); + $result = Tools::getInput ($dat, 'data', 'base64'); + $this->assertEquals ('hello world', $result); + } + + // --------------------------------------------------------------- + // Raw type tests + // --------------------------------------------------------------- + + public function testRawCheckInput () + { + $this->assertTrue (Tools::checkInput ('anything', 'raw')); + $this->assertTrue (Tools::checkInput ('', 'raw')); + } + + public function testRawGetInput () + { + $dat = array ('data' => ''); + $result = Tools::getInput ($dat, 'data', 'raw'); + $this->assertEquals ('', $result); + } + + public function testHtmlGetInput () + { + $dat = array ('data' => 'bold'); + $result = Tools::getInput ($dat, 'data', 'html'); + $this->assertEquals ('bold', $result); + } + + // --------------------------------------------------------------- + // Unknown type tests + // --------------------------------------------------------------- + + public function testUnknownTypeReturnsFalse () + { + $this->assertFalse (Tools::checkInput ('value', 'nonexistent_type')); + } + + // --------------------------------------------------------------- + // getInput default behavior tests + // --------------------------------------------------------------- + + public function testGetInputMissingKey () + { + $dat = array (); + $this->assertNull (Tools::getInput ($dat, 'missing', 'text')); + } + + public function testGetInputMissingKeyWithDefault () + { + $dat = array (); + $this->assertEquals ('fallback', Tools::getInput ($dat, 'missing', 'text', 'fallback')); + } + + public function testGetInputStripsTagsByDefault () + { + $dat = array ('name' => 'John Doe'); + $result = Tools::getInput ($dat, 'name', 'varchar'); + $this->assertEquals ('John Doe', $result); + } + + // --------------------------------------------------------------- + // SQL injection patterns via checkInput + // --------------------------------------------------------------- + + public function testSqlInjectionInEmailField () + { + $this->assertFalse (Tools::checkInput ("' OR '1'='1", 'email')); + $this->assertFalse (Tools::checkInput ("admin@example.com' OR 1=1--", 'email')); + $this->assertFalse (Tools::checkInput ("admin@example.com'; DROP TABLE users;--", 'email')); + $this->assertFalse (Tools::checkInput ("' UNION SELECT * FROM users--", 'email')); + } + + public function testSqlInjectionInUsernameField () + { + $this->assertFalse (Tools::checkInput ("' OR '1'='1", 'username')); + $this->assertFalse (Tools::checkInput ("admin'; DROP TABLE users;--", 'username')); + $this->assertFalse (Tools::checkInput ("admin' UNION SELECT * FROM users--", 'username')); + $this->assertFalse (Tools::checkInput ("1; DROP TABLE users", 'username')); + } + + public function testSqlInjectionInDateField () + { + $this->assertFalse (Tools::checkInput ("'; DROP TABLE users;--", 'date')); + $this->assertFalse (Tools::checkInput ("2015-01-01' OR '1'='1", 'date')); + $this->assertFalse (Tools::checkInput ("2015-01-01; DROP TABLE users;--", 'date')); + $this->assertFalse (Tools::checkInput ("1 OR 1=1", 'date')); + $this->assertFalse (Tools::checkInput ("' UNION SELECT * FROM users--", 'date')); + } + + public function testSqlInjectionInDatetimeField () + { + $this->assertFalse (Tools::checkInput ("'; DROP TABLE users;--", 'datetime')); + $this->assertFalse (Tools::checkInput ("not-a-datetime", 'datetime')); + } + + public function testSqlInjectionInIntField () + { + $this->assertFalse (Tools::checkInput ("1; DROP TABLE users", 'int')); + $this->assertFalse (Tools::checkInput ("1 OR 1=1", 'int')); + $this->assertFalse (Tools::checkInput ("' OR '1'='1", 'int')); + $this->assertFalse (Tools::checkInput ("1 UNION SELECT * FROM users", 'int')); + } + + public function testSqlInjectionInNumberField () + { + $this->assertFalse (Tools::checkInput ("1; DROP TABLE users", 'number')); + $this->assertFalse (Tools::checkInput ("1 OR 1=1", 'number')); + $this->assertFalse (Tools::checkInput ("' OR '1'='1", 'number')); + } + + public function testSqlInjectionInMd5Field () + { + $this->assertFalse (Tools::checkInput ("' OR '1'='1' --", 'md5')); + $this->assertFalse (Tools::checkInput ("'; DROP TABLE users;--", 'md5')); + } + + public function testSqlInjectionInPasswordField () + { + // These pass checkInput because password only checks length/UTF-8 + // But they should still be properly escaped before use in queries + $this->assertTrue (Tools::checkInput ("password' OR '1'='1", 'password')); + } + + public function testSqlInjectionInUrlField () + { + $this->assertFalse (Tools::checkInput ("'; DROP TABLE users;--", 'url')); + $this->assertFalse (Tools::checkInput ("' OR '1'='1", 'url')); + } + + // --------------------------------------------------------------- + // XSS injection patterns via getInput + // --------------------------------------------------------------- + + public function testXssStrippedByGetInputDefault () + { + $dat = array ('name' => ''); + $result = Tools::getInput ($dat, 'name', 'varchar'); + $this->assertStringNotContainsString (''); + $this->assertStringNotContainsString ('