diff --git a/system/Cache/Handlers/FileHandler.php b/system/Cache/Handlers/FileHandler.php index adc31c8f447c..4ede81dec354 100644 --- a/system/Cache/Handlers/FileHandler.php +++ b/system/Cache/Handlers/FileHandler.php @@ -112,9 +112,20 @@ public function save(string $key, mixed $value, int $ttl = 60): bool public function delete(string $key): bool { - $key = static::validateKey($key, $this->prefix); + $key = static::validateKey($key, $this->prefix); + $file = $this->path . $key; + + if (! is_file($file)) { + return false; + } + + $result = @unlink($file); - return is_file($this->path . $key) && unlink($this->path . $key); + if ($result === false) { + log_message('error', 'Failed to delete cache file: ' . $file); + } + + return $result; } public function deleteMatching(string $pattern): int @@ -122,8 +133,12 @@ public function deleteMatching(string $pattern): int $deleted = 0; foreach (glob($this->path . $pattern, GLOB_NOSORT) as $filename) { - if (is_file($filename) && @unlink($filename)) { - $deleted++; + if (is_file($filename)) { + if (@unlink($filename)) { + $deleted++; + } else { + log_message('error', 'Failed to delete cache file: ' . $filename); + } } } @@ -200,6 +215,8 @@ protected function getItem(string $filename): array|false $content = @file_get_contents($this->path . $filename); if ($content === false) { + log_message('error', 'Failed to read cache file: ' . $this->path . $filename); + return false; } @@ -222,7 +239,11 @@ protected function getItem(string $filename): array|false } if ($data['ttl'] > 0 && Time::now()->getTimestamp() > $data['time'] + $data['ttl']) { - @unlink($this->path . $filename); + $file = $this->path . $filename; + + if (is_file($file) && ! @unlink($file)) { + log_message('error', 'Failed to delete expired cache file: ' . $file); + } return false; } @@ -242,6 +263,8 @@ protected function getItem(string $filename): array|false protected function writeFile($path, $data, $mode = 'wb'): bool { if (($fp = @fopen($path, $mode)) === false) { + log_message('error', 'Failed to open cache file for writing: ' . $path); + return false; } @@ -280,6 +303,8 @@ protected function deleteFiles(string $path, bool $delDir = false, bool $htdocs $path = rtrim($path, '/\\'); if (! $currentDir = @opendir($path)) { + log_message('error', 'Failed to open cache directory: ' . $path); + return false; } @@ -288,14 +313,27 @@ protected function deleteFiles(string $path, bool $delDir = false, bool $htdocs if (is_dir($path . DIRECTORY_SEPARATOR . $filename) && $filename[0] !== '.') { $this->deleteFiles($path . DIRECTORY_SEPARATOR . $filename, $delDir, $htdocs, $_level + 1); } elseif (! $htdocs || preg_match('/^(\.htaccess|index\.(html|htm|php)|web\.config)$/i', $filename) !== 1) { - @unlink($path . DIRECTORY_SEPARATOR . $filename); + $file = $path . DIRECTORY_SEPARATOR . $filename; + if (! @unlink($file)) { + log_message('error', 'Failed to delete cache file: ' . $file); + } } } } closedir($currentDir); - return ($delDir && $_level > 0) ? @rmdir($path) : true; + if ($delDir && $_level > 0) { + $result = @rmdir($path); + + if (! $result) { + log_message('error', 'Failed to remove cache directory: ' . $path); + } + + return $result; + } + + return true; } /** diff --git a/system/Database/BaseResult.php b/system/Database/BaseResult.php index c0bdc2aa1025..7d249cab8c6b 100644 --- a/system/Database/BaseResult.php +++ b/system/Database/BaseResult.php @@ -333,7 +333,7 @@ public function getRowArray(int $n = 0) $this->currentRow = $n; } - return $result[$this->currentRow]; + return $result[$this->currentRow] ?? null; } /** @@ -350,11 +350,11 @@ public function getRowObject(int $n = 0) return null; } - if ($n !== $this->customResultObject && isset($result[$n])) { + if ($n !== $this->currentRow && isset($result[$n])) { $this->currentRow = $n; } - return $result[$this->currentRow]; + return $result[$this->currentRow] ?? null; } /** diff --git a/system/Model.php b/system/Model.php index f4ddd75276d3..0e1cf9da9c3b 100644 --- a/system/Model.php +++ b/system/Model.php @@ -713,7 +713,7 @@ public function update($id = null, $row = null): bool protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array { - return parent::objectToRawArray($object, $onlyChanged); + return parent::objectToRawArray($object, $onlyChanged, $recursive); } /** diff --git a/system/Security/Security.php b/system/Security/Security.php index 4ac0de3f8ff8..d18e237e5926 100644 --- a/system/Security/Security.php +++ b/system/Security/Security.php @@ -295,6 +295,7 @@ private function removeTokenInRequest(IncomingRequest $request): void try { $json = json_decode($body, flags: JSON_THROW_ON_ERROR); } catch (JsonException) { + log_message('error', 'Invalid JSON in request body during CSRF token removal'); $json = null; } @@ -346,6 +347,7 @@ private function getPostedToken(IncomingRequest $request): ?string try { $json = json_decode($body, flags: JSON_THROW_ON_ERROR); } catch (JsonException) { + log_message('error', 'Invalid JSON in request body during CSRF token retrieval'); $json = null; } diff --git a/tests/system/Cache/Handlers/FileHandlerTest.php b/tests/system/Cache/Handlers/FileHandlerTest.php index c4fa8481bbd4..429d6aad6b89 100644 --- a/tests/system/Cache/Handlers/FileHandlerTest.php +++ b/tests/system/Cache/Handlers/FileHandlerTest.php @@ -398,4 +398,87 @@ public function testReconnect(): void { $this->assertTrue($this->handler->reconnect()); } + + public function testDeleteReturnsTrueForExistingFile(): void + { + $this->handler->save(self::$key1, 'value'); + + $this->assertTrue($this->handler->delete(self::$key1)); + $this->assertNull($this->handler->get(self::$key1)); + } + + public function testDeleteReturnsFalseForNonExistentFile(): void + { + $this->assertFalse($this->handler->delete(self::$dummy)); + } + + public function testGetItemWithCorruptedDataDoesNotLogError(): void + { + $filePath = $this->config->file['storePath'] . DIRECTORY_SEPARATOR + . $this->config->prefix . self::$key2; + + file_put_contents($filePath, 'corrupted_serialized_data_that_cannot_be_unserialized'); + + $this->assertNull($this->handler->get(self::$key2)); + + // Verify it did not log a "read cache file" error + $this->assertNull($this->handler->getMetaData(self::$key2)); + } + + public function testGetItemWithExpiredFileDeletesWithoutError(): void + { + // Save with 0 TTL (permanent) then manually modify the file to have a past time + $this->handler->save(self::$key3, 'value', 0); + + $filePath = $this->config->file['storePath'] . DIRECTORY_SEPARATOR + . $this->config->prefix . self::$key3; + + // Overwrite with expired data + $expiredData = serialize(['data' => 'value', 'ttl' => 1, 'time' => 100]); + file_put_contents($filePath, $expiredData); + + $this->assertNull($this->handler->get(self::$key3)); + $this->assertFileDoesNotExist($filePath); + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testDeleteWithUnwritableDirectoryLogsError(): void + { + $this->handler->save(self::$key1, 'value'); + + // Make the cache directory read-only so unlink fails + chmod($this->config->file['storePath'], 0555); + + $this->handler->delete(self::$key1); + + // Restore permissions before assertions + chmod($this->config->file['storePath'], 0777); + + // Verify log message was recorded + $this->assertLogContains('error', 'Failed to delete cache file'); + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testDeleteMatchingWithUnwritableDirectoryLogsError(): void + { + $this->handler->save(self::$key1, 'value'); + $this->handler->save(self::$key2, 'value'); + + chmod($this->config->file['storePath'], 0555); + + $this->handler->deleteMatching('*'); + + chmod($this->config->file['storePath'], 0777); + + $this->assertLogContains('error', 'Failed to delete cache file'); + } + + public function testCleanRemovesAllFiles(): void + { + $this->handler->save(self::$key1, 'value'); + $this->handler->save(self::$key2, 'value'); + + $this->assertTrue($this->handler->clean()); + $this->assertCount(0, $this->handler->getCacheInfo()); + } } diff --git a/tests/system/Database/BaseResultTest.php b/tests/system/Database/BaseResultTest.php new file mode 100644 index 000000000000..26fd3a18e153 --- /dev/null +++ b/tests/system/Database/BaseResultTest.php @@ -0,0 +1,249 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; +use stdClass; + +/** + * @internal + */ +#[Group('Database')] +final class BaseResultTest extends CIUnitTestCase +{ + /** + * Create a minimal concrete implementation of BaseResult for testing. + */ + private function createResultDouble(array $resultArray, array $resultObject): BaseResult + { + return new class ($resultArray, $resultObject) extends BaseResult { + public function __construct(array $resultArray, array $resultObject) + { + $this->resultArray = $resultArray; + $this->resultObject = $resultObject; + $this->currentRow = 0; + + $connId = null; + $resultId = null; + parent::__construct($connId, $resultId); + } + + public function getFieldCount(): int + { + return 0; + } + + public function getFieldNames(): array + { + return []; + } + + public function getFieldData(): array + { + return []; + } + + public function freeResult(): void + { + } + + public function dataSeek(int $n = 0): bool + { + return true; + } + + protected function fetchAssoc() + { + return false; + } + + protected function fetchObject(string $className = stdClass::class) + { + return false; + } + }; + } + + // -------------------------------------------------------------------- + // getRowArray() + // -------------------------------------------------------------------- + + public function testGetRowArrayReturnsRow(): void + { + $result = $this->createResultDouble( + [ + ['id' => 1, 'name' => 'John'], + ['id' => 2, 'name' => 'Jane'], + ], + [], + ); + + $this->assertSame(['id' => 1, 'name' => 'John'], $result->getRowArray(0)); + $this->assertSame(['id' => 2, 'name' => 'Jane'], $result->getRowArray(1)); + } + + public function testGetRowArrayReturnsNullForEmptyResult(): void + { + $result = $this->createResultDouble([], []); + + $this->assertNull($result->getRowArray(0)); + } + + public function testGetRowArrayReturnsFirstRowByDefault(): void + { + $result = $this->createResultDouble( + [ + ['id' => 1, 'name' => 'John'], + ['id' => 2, 'name' => 'Jane'], + ], + [], + ); + + $this->assertSame(['id' => 1, 'name' => 'John'], $result->getRowArray()); + } + + // -------------------------------------------------------------------- + // getRowObject() + // -------------------------------------------------------------------- + + public function testGetRowObjectReturnsObject(): void + { + $row1 = new stdClass(); + $row1->id = 1; + $row1->name = 'John'; + $row2 = new stdClass(); + $row2->id = 2; + $row2->name = 'Jane'; + + $result = $this->createResultDouble([], [$row1, $row2]); + + $this->assertEquals($row1, $result->getRowObject(0)); + $this->assertEquals($row2, $result->getRowObject(1)); + } + + public function testGetRowObjectReturnsNullForEmptyResult(): void + { + $result = $this->createResultDouble([], []); + + $this->assertNull($result->getRowObject(0)); + } + + public function testGetRowObjectReturnsFirstRowByDefault(): void + { + $row1 = new stdClass(); + $row1->id = 1; + $row1->name = 'John'; + + $result = $this->createResultDouble([], [$row1]); + + $this->assertEquals($row1, $result->getRowObject()); + } + + public function testGetRowObjectAndGetRowArrayShareCurrentRow(): void + { + $row1 = new stdClass(); + $row1->id = 1; + $row1->name = 'John'; + $row2 = new stdClass(); + $row2->id = 2; + $row2->name = 'Jane'; + + $result = $this->createResultDouble( + [ + ['id' => 1, 'name' => 'John'], + ['id' => 2, 'name' => 'Jane'], + ], + [$row1, $row2], + ); + + // getRowObject(1) should advance currentRow to 1 (same as getRowArray would) + $result->getRowObject(1); + $this->assertSame(['id' => 2, 'name' => 'Jane'], $result->getRowArray(1)); + } + + public function testGetRowObjectUsesCurrentRowLikeGetRowArray(): void + { + $row1 = new stdClass(); + $row1->id = 1; + $row1->name = 'John'; + $row2 = new stdClass(); + $row2->id = 2; + $row2->name = 'Jane'; + + $result = $this->createResultDouble( + [ + ['id' => 1, 'name' => 'John'], + ['id' => 2, 'name' => 'Jane'], + ], + [$row1, $row2], + ); + + // Both methods should advance currentRow consistently + $result->getRowObject(1); + $result->getRowArray(); + $this->assertEquals($row1, $result->getRowObject()); + } + + // -------------------------------------------------------------------- + // getRow() — convenience wrapper + // -------------------------------------------------------------------- + + public function testGetRowWithInvalidIndexReturnsFirstRow(): void + { + $result = $this->createResultDouble( + [['id' => 1, 'name' => 'John']], + [], + ); + + $this->assertSame(['id' => 1, 'name' => 'John'], $result->getRow(999, 'array')); + } + + public function testGetRowObjectWithInvalidIndexReturnsFirstRow(): void + { + $row1 = new stdClass(); + $row1->id = 1; + $row1->name = 'John'; + + $result = $this->createResultDouble([], [$row1]); + + $this->assertEquals($row1, $result->getRow(999, 'object')); + } + + public function testGetRowNullForColumnNameNotFound(): void + { + $result = $this->createResultDouble( + [['id' => 1, 'name' => 'John']], + [], + ); + + $this->assertNull($result->getRow('nonexistent', 'array')); + } + + // -------------------------------------------------------------------- + // Custom Result Object + // -------------------------------------------------------------------- + + public function testGetCustomRowObjectReturnsNullForOutOfBounds(): void + { + $row = new stdClass(); + $row->id = 1; + $row->name = 'John'; + + $result = $this->createResultDouble([], [$row]); + $result->getCustomResultObject(stdClass::class); + + $this->assertNull($result->getCustomRowObject(999, stdClass::class)); + } +} diff --git a/tests/system/Models/ObjectToRawArrayModelTest.php b/tests/system/Models/ObjectToRawArrayModelTest.php new file mode 100644 index 000000000000..7ea2ad24dcf2 --- /dev/null +++ b/tests/system/Models/ObjectToRawArrayModelTest.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Models; + +use CodeIgniter\Entity\Entity; +use CodeIgniter\Model; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; +use ReflectionMethod; + +/** + * @internal + */ +#[Group('Models')] +final class ObjectToRawArrayModelTest extends CIUnitTestCase +{ + private function createModel(): Model + { + return new class () extends Model { + public function __construct() + { + // Skip DB connection — we only test objectToRawArray + } + + protected $table = 'test'; + protected $allowedFields = ['name', 'nested', 'entity']; + protected $returnType = 'array'; + protected $useSoftDeletes = false; + }; + } + + /** + * Call protected objectToRawArray via reflection. + */ + private function callObjectToRawArray(Model $model, object $object, bool $onlyChanged, bool $recursive): array + { + $method = new ReflectionMethod(Model::class, 'objectToRawArray'); + + return $method->invoke($model, $object, $onlyChanged, $recursive); + } + + public function testObjectToRawArrayPassesRecursiveTrue(): void + { + $model = $this->createModel(); + + $inner = new class () extends Entity { + protected $attributes = ['name' => 'inner']; + protected $original = ['name' => 'inner']; + }; + + $outer = new class () extends Entity { + protected $attributes = ['name' => 'outer', 'nested' => null]; + protected $original = ['name' => 'outer', 'nested' => null]; + }; + $outer->nested = $inner; + + $result = $this->callObjectToRawArray($model, $outer, false, true); + + $this->assertArrayHasKey('name', $result); + $this->assertSame('outer', $result['name']); + $this->assertArrayHasKey('nested', $result); + $this->assertIsArray($result['nested']); + $this->assertSame(['name' => 'inner'], $result['nested']); + } + + public function testObjectToRawArrayPassesRecursiveFalse(): void + { + $model = $this->createModel(); + + $inner = new class () extends Entity { + protected $attributes = ['name' => 'inner']; + protected $original = ['name' => 'inner']; + }; + + $outer = new class () extends Entity { + protected $attributes = ['name' => 'outer', 'nested' => null]; + protected $original = ['name' => 'outer', 'nested' => null]; + }; + $outer->nested = $inner; + + $result = $this->callObjectToRawArray($model, $outer, false, false); + + $this->assertArrayHasKey('name', $result); + $this->assertSame('outer', $result['name']); + $this->assertArrayHasKey('nested', $result); + // With recursive=false, nested Entity should remain as object + $this->assertInstanceOf(Entity::class, $result['nested']); + } + + public function testObjectToRawArrayNonEntity(): void + { + $model = $this->createModel(); + + $obj = new class () { + public string $name = 'test'; + public string $value = '123'; + }; + + $result = $this->callObjectToRawArray($model, $obj, false, false); + + $this->assertSame(['name' => 'test', 'value' => '123'], $result); + } + + public function testObjectToRawArrayOnlyChanged(): void + { + $model = $this->createModel(); + + $entity = new class () extends Entity { + protected $attributes = ['name' => 'original', 'value' => 'keep']; + protected $original = ['name' => 'original', 'value' => 'keep']; + }; + $entity->name = 'modified'; + + $result = $this->callObjectToRawArray($model, $entity, true, false); + + $this->assertSame(['name' => 'modified'], $result); + } +}