diff --git a/composer.json.dist b/composer.json.dist index e36fb0db0..4b7e8167e 100644 --- a/composer.json.dist +++ b/composer.json.dist @@ -46,6 +46,10 @@ "wikimedia/less.php": "^5.0", "xoops/regdom": "^2.0" }, + "suggest": { + "ext-bcmath": "Needed by Xmf\\Ulid to convert ULIDs to and from UUIDs", + "ext-zlib": "Needed by Xmf\\Security\\Serializer for gzip-compressed payloads" + }, "require-dev": { "phpunit/phpunit": "^11.0", "roave/security-advisories": "dev-master" diff --git a/docs/changelog.26.txt b/docs/changelog.26.txt index eca514a8c..a52c1bf3a 100644 --- a/docs/changelog.26.txt +++ b/docs/changelog.26.txt @@ -8,6 +8,7 @@ Updated: - jQuery to 3.7.0 (mamba) - jQueryUI to 1.13.2 (mamba) - jQuery Form to 4.3.0 (mamba) + - XMF to 1.3.2, keeping the 2.6 Doctrine/Xoops adapters (mamba) ================================== 2013/04/15: Version 2.6.0 Alpha 3 diff --git a/tests/unit/xoopsLib/Xmf/Database/MigrateFlowTest.php b/tests/unit/xoopsLib/Xmf/Database/MigrateFlowTest.php new file mode 100644 index 000000000..b1a2ed32b --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/Database/MigrateFlowTest.php @@ -0,0 +1,269 @@ +yamlFile && file_exists($this->yamlFile)) { + unlink($this->yamlFile); + } + } + + private function migrate(array $tables, RecordingTableHandler $handler, ?array $target = null): FlowMigrate + { + $migrate = new FlowMigrate(); + $migrate->moduleTables = $tables; + $migrate->tableHandler = $handler; + $migrate->targetDefinitions = $target; + return $migrate; + } + + private static function target(): array + { + return [ + 'items' => [ + 'options' => 'ENGINE=InnoDB', + 'columns' => [ + ['name' => 'id', 'attributes' => 'int NOT NULL'], + ['name' => 'title', 'attributes' => 'varchar(255) NOT NULL'], + ['name' => 'added', 'attributes' => 'int NOT NULL DEFAULT 0'], + ], + 'keys' => [ + 'PRIMARY' => ['columns' => '`id`', 'unique' => true], + 'idx_title' => ['columns' => '`title`', 'unique' => true], + 'idx_new' => ['columns' => '`added`', 'unique' => false], + ], + ], + 'logs' => [ + 'options' => 'ENGINE=InnoDB', + 'columns' => [['name' => 'log_id', 'attributes' => 'int NOT NULL']], + ], + ]; + } + + public function testGetSynchronizeDdlSynchronizesExistingAndAddsMissingTables(): void + { + $handler = new RecordingTableHandler(); + $handler->existingTables = ['items']; + $handler->existingColumns = ['id' => 'int NOT NULL', 'title' => 'varchar(100) NOT NULL']; + $handler->dumpTables = ['items' => ['columns' => [ + ['name' => 'id'], ['name' => 'title'], ['name' => 'obsolete'], + ]]]; + $handler->existingIndexes = [ + 'PRIMARY' => ['columns' => '`id`, `title`', 'unique' => true], + 'idx_title' => ['columns' => '`title`', 'unique' => false], + 'idx_gone' => ['columns' => '`obsolete`', 'unique' => false], + ]; + + $queue = $this->migrate(['items', 'logs'], $handler, self::target())->getSynchronizeDDL(); + + $this->assertSame(['queued'], $queue); + $this->assertSame([ + ['alterColumn', 'items', 'title', 'varchar(255) NOT NULL'], + ['addColumn', 'items', 'added', 'int NOT NULL DEFAULT 0'], + ['dropColumn', 'items', 'obsolete'], + ['dropPrimaryKey', 'items'], + ['addPrimaryKey', 'items', '`id`'], + ['dropIndex', 'idx_title', 'items'], + ['addIndex', 'idx_title', 'items', '`title`', true], + ['addIndex', 'idx_new', 'items', '`added`', false], + ['dropIndex', 'idx_gone', 'items'], + ['addTable', 'logs'], + ['setTableOptions', 'logs', 'ENGINE=InnoDB'], + ['addColumn', 'logs', 'log_id', 'int NOT NULL'], + ], $handler->calls); + } + + public function testSynchronizeAddsPrimaryKeyWhenMissing(): void + { + $handler = new RecordingTableHandler(); + $handler->existingTables = ['items']; + $handler->existingColumns = [ + 'id' => 'int NOT NULL', 'title' => 'varchar(255) NOT NULL', 'added' => 'int NOT NULL DEFAULT 0', + ]; + $handler->existingIndexes = [ + 'idx_title' => ['columns' => '`title`', 'unique' => true], + 'idx_new' => ['columns' => '`added`', 'unique' => false], + ]; + + $this->migrate(['items'], $handler, self::target())->getSynchronizeDDL(); + + $this->assertSame([['addPrimaryKey', 'items', '`id`']], $handler->calls); + } + + public function testSynchronizeToleratesMissingIndexList(): void + { + $handler = new RecordingTableHandler(); + $handler->existingTables = ['logs']; + $handler->existingColumns = ['log_id' => 'int NOT NULL']; + $handler->existingIndexes = false; + + $this->migrate(['logs'], $handler, self::target())->getSynchronizeDDL(); + + $this->assertSame([], $handler->calls); + } + + public function testSynchronizeRejectsTableWithoutTargetDefinition(): void + { + $handler = new RecordingTableHandler(); + $handler->existingTables = ['unknown']; + + $this->expectException(SchemaDefinitionException::class); + $this->migrate(['unknown'], $handler, self::target())->getSynchronizeDDL(); + } + + public function testNonPrimaryKeyMustDeclareUniqueness(): void + { + $target = self::target(); + unset($target['items']['keys']['idx_new']['unique']); + + $this->expectException(SchemaDefinitionException::class); + $this->migrate(['items'], new RecordingTableHandler(), $target)->getSynchronizeDDL(); + } + + public function testNonStringKeyNamesAreIgnored(): void + { + $target = ['logs' => [ + 'options' => 'ENGINE=InnoDB', + 'columns' => [['name' => 'log_id', 'attributes' => 'int NOT NULL']], + 'keys' => [0 => ['columns' => '`log_id`', 'unique' => false]], + ]]; + $handler = new RecordingTableHandler(); + + $this->migrate(['logs'], $handler, $target)->getSynchronizeDDL(); + + $this->assertSame([ + ['addTable', 'logs'], + ['setTableOptions', 'logs', 'ENGINE=InnoDB'], + ['addColumn', 'logs', 'log_id', 'int NOT NULL'], + ], $handler->calls); + } + + public function testGetCurrentSchemaAddsTablesTheHandlerDoesNotKnow(): void + { + $handler = new RecordingTableHandler(); + $handler->existingTables = ['items']; + $handler->dumpTables = ['items' => ['name' => 'x_items']]; + + $schema = $this->migrate(['items', 'logs'], $handler)->getCurrentSchema(); + + $this->assertSame(['items' => ['name' => 'x_items'], 'logs' => ['name' => 'x_logs']], $schema); + $this->assertSame([['addTable', 'logs']], $handler->calls); + } + + public function testGetTargetDefinitionsReadsYamlFile(): void + { + $this->yamlFile = tempnam(sys_get_temp_dir(), 'xmfmig'); + Yaml::save(self::target(), $this->yamlFile); + $migrate = $this->migrate([], new RecordingTableHandler()); + $migrate->tableDefinitionFile = $this->yamlFile; + + $this->assertSame(self::target(), $migrate->getTargetDefinitions()); + } + + public function testGetTargetDefinitionsRejectsMissingFile(): void + { + $migrate = $this->migrate([], new RecordingTableHandler()); + $migrate->tableDefinitionFile = sys_get_temp_dir() . '/xmf-no-such-schema.yml'; + + $this->expectException(SchemaDefinitionException::class); + $migrate->getTargetDefinitions(); + } + + public function testLastErrorComesFromTheTableHandler(): void + { + $handler = new RecordingTableHandler(); + $migrate = $this->migrate([], $handler); + + $this->assertSame('handler error', $migrate->getLastError()); + $this->assertSame(1146, $migrate->getLastErrNo()); + } +} + +class FlowMigrate extends Migrate +{ + public $moduleTables; + public $tableHandler; + public $targetDefinitions; + public $tableDefinitionFile; + + public function __construct() + { + // skip the parent constructor: it needs a database connection + } +} + +class RecordingTableHandler +{ + public $calls = []; + public $existingTables = []; + public $existingColumns = []; + public $dumpTables = []; + /** @var array|false */ + public $existingIndexes = []; + + public function __call($name, $args) + { + $this->calls[] = array_merge([$name], $args); + return true; + } + + // like Tables::addTable(), a new table shows up in dumpTables() + public function addTable($table) + { + $this->calls[] = ['addTable', $table]; + $this->dumpTables[$table] = ['name' => 'x_' . $table]; + return true; + } + + public function useTable($table) + { + return in_array($table, $this->existingTables, true); + } + + public function getColumnAttributes($table, $column) // NOSONAR $table mirrors the Tables API + { + return $this->existingColumns[$column] ?? false; + } + + public function dumpTables() + { + return $this->dumpTables; + } + + public function getTableIndexes($table) // NOSONAR $table mirrors the Tables API + { + return $this->existingIndexes; + } + + public function dumpQueue() + { + return ['queued']; + } + + public function getLastError() + { + return 'handler error'; + } + + public function getLastErrNo() + { + return 1146; + } +} diff --git a/tests/unit/xoopsLib/Xmf/Database/MigrateUnitTest.php b/tests/unit/xoopsLib/Xmf/Database/MigrateUnitTest.php new file mode 100644 index 000000000..128d64071 --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/Database/MigrateUnitTest.php @@ -0,0 +1,235 @@ +setTargetDefinitions(array( + 'demo' => array( + 'options' => 'ENGINE=InnoDB', + 'columns' => array( + array('attributes' => 'int NOT NULL'), + ), + ), + )); + $migrate->setTableHandler(new FakeMigrateTableHandler()); + + $this->expectException(SchemaDefinitionException::class); + $this->expectExceptionMessage('No schema definition for table demo'); + + $migrate->callAddMissingTable('demo'); + } + + public function testAddMissingTableAddsValidatedColumnsAndKeys() + { + $migrate = new TestableMigrate(); + $migrate->setTargetDefinitions(array( + 'demo' => array( + 'options' => 'ENGINE=InnoDB', + 'columns' => array( + array('name' => 'id', 'attributes' => 'int NOT NULL'), + array('name' => 'title', 'attributes' => 'varchar(255) NOT NULL'), + ), + 'keys' => array( + 'PRIMARY' => array('columns' => '`id`'), + 'idx_title' => array('columns' => '`title`', 'unique' => false), + ), + ), + )); + $handler = new FakeMigrateTableHandler(); + $migrate->setTableHandler($handler); + + $migrate->callAddMissingTable('demo'); + + $this->assertSame( + array( + array('addTable', 'demo'), + array('setTableOptions', 'demo', 'ENGINE=InnoDB'), + array('addColumn', 'demo', 'id', 'int NOT NULL'), + array('addColumn', 'demo', 'title', 'varchar(255) NOT NULL'), + array('addPrimaryKey', 'demo', '`id`'), + array('addIndex', 'idx_title', 'demo', '`title`', false), + ), + $handler->calls + ); + } + + public function testSynchronizeTableThrowsForMalformedKeyDefinition() + { + $migrate = new TestableMigrate(); + $migrate->setTargetDefinitions(array( + 'demo' => array( + 'columns' => array( + array('name' => 'id', 'attributes' => 'int NOT NULL'), + ), + 'keys' => array( + 'idx_title' => array('columns' => '`title`', 'unique' => 'yes'), + ), + ), + )); + $handler = new FakeMigrateTableHandler(); + $handler->existingColumns = array('id' => 'int NOT NULL'); + $handler->dumpTables = array( + 'demo' => array( + 'columns' => array( + array('name' => 'id'), + ), + ), + ); + $handler->existingIndexes = array(); + $migrate->setTableHandler($handler); + + $this->expectException(SchemaDefinitionException::class); + $this->expectExceptionMessage('No schema definition for table demo'); + + $migrate->callSynchronizeTable('demo'); + } + + public function testSynchronizeTableDropsPrimaryKeyWhenTargetOmitsIt() + { + $migrate = new TestableMigrate(); + $migrate->setTargetDefinitions(array( + 'demo' => array( + 'columns' => array( + array('name' => 'id', 'attributes' => 'int unsigned NOT NULL'), + array('name' => 'title', 'attributes' => 'varchar(255) NOT NULL'), + ), + ), + )); + $handler = new FakeMigrateTableHandler(); + $handler->existingColumns = array('id' => 'int NOT NULL'); + $handler->dumpTables = array( + 'demo' => array( + 'columns' => array( + array('name' => 'id'), + array('name' => 'obsolete'), + ), + ), + ); + $handler->existingIndexes = array( + 'PRIMARY' => array('columns' => '`id`', 'unique' => true), + 'idx_old' => array('columns' => '`obsolete`', 'unique' => false), + ); + $migrate->setTableHandler($handler); + + $migrate->callSynchronizeTable('demo'); + + $this->assertContains(array('alterColumn', 'demo', 'id', 'int unsigned NOT NULL'), $handler->calls); + $this->assertContains(array('addColumn', 'demo', 'title', 'varchar(255) NOT NULL'), $handler->calls); + $this->assertContains(array('dropColumn', 'demo', 'obsolete'), $handler->calls); + $this->assertContains(array('dropPrimaryKey', 'demo'), $handler->calls); + $this->assertContains(array('dropIndex', 'idx_old', 'demo'), $handler->calls); + } +} + +class TestableMigrate extends Migrate +{ + public function __construct() + { + // skip the parent constructor: it needs a database connection + } + + public function setTargetDefinitions(array $targetDefinitions): void + { + $this->targetDefinitions = $targetDefinitions; + } + + public function setTableHandler(object $tableHandler): void + { + $this->tableHandler = $tableHandler; + } + + public function callAddMissingTable(string $tableName): void + { + $this->addMissingTable($tableName); + } + + public function callSynchronizeTable(string $tableName): void + { + $this->synchronizeTable($tableName); + } +} + +class FakeMigrateTableHandler +{ + public array $calls = array(); + public array $existingColumns = array(); + public array $dumpTables = array(); + public array|false $existingIndexes = array(); + + public function addTable(string $tableName): bool + { + $this->calls[] = array('addTable', $tableName); + return true; + } + + public function setTableOptions(string $tableName, string $options): bool + { + $this->calls[] = array('setTableOptions', $tableName, $options); + return true; + } + + public function addColumn(string $tableName, string $name, string $attributes): bool + { + $this->calls[] = array('addColumn', $tableName, $name, $attributes); + return true; + } + + public function addPrimaryKey(string $tableName, string $columns): bool + { + $this->calls[] = array('addPrimaryKey', $tableName, $columns); + return true; + } + + public function addIndex(string $name, string $tableName, string $columns, bool $unique): bool + { + $this->calls[] = array('addIndex', $name, $tableName, $columns, $unique); + return true; + } + + public function getColumnAttributes(string $tableName, string $name): string|false // NOSONAR mirrors Tables + { + return $this->existingColumns[$name] ?? false; + } + + public function alterColumn(string $tableName, string $name, string $attributes): bool + { + $this->calls[] = array('alterColumn', $tableName, $name, $attributes); + return true; + } + + public function dumpTables(): array + { + return $this->dumpTables; + } + + public function getTableIndexes(string $tableName): array|false // NOSONAR mirrors Tables + { + return $this->existingIndexes; + } + + public function dropPrimaryKey(string $tableName): bool + { + $this->calls[] = array('dropPrimaryKey', $tableName); + return true; + } + + public function dropIndex(string $name, string $tableName): bool + { + $this->calls[] = array('dropIndex', $name, $tableName); + return true; + } + + public function dropColumn(string $tableName, string $name): bool + { + $this->calls[] = array('dropColumn', $tableName, $name); + return true; + } +} diff --git a/tests/unit/xoopsLib/Xmf/Database/SchemaDefinitionExceptionTest.php b/tests/unit/xoopsLib/Xmf/Database/SchemaDefinitionExceptionTest.php new file mode 100644 index 000000000..0a55187cf --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/Database/SchemaDefinitionExceptionTest.php @@ -0,0 +1,22 @@ +assertSame('No schema definition schema.yml', $exception->getMessage()); + } + + public function testForTableBuildsExpectedMessage() + { + $exception = SchemaDefinitionException::forTable('xoops_users'); + + $this->assertSame('No schema definition for table xoops_users', $exception->getMessage()); + } +} diff --git a/tests/unit/xoopsLib/Xmf/Database/TablesDefaultClauseTest.php b/tests/unit/xoopsLib/Xmf/Database/TablesDefaultClauseTest.php new file mode 100644 index 000000000..3051f5718 --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/Database/TablesDefaultClauseTest.php @@ -0,0 +1,36 @@ + [null, ''], + 'mysql timestamp' => ['CURRENT_TIMESTAMP', ' DEFAULT CURRENT_TIMESTAMP '], + 'mariadb timestamp' => ['current_timestamp()', ' DEFAULT CURRENT_TIMESTAMP() '], + 'timestamp precision' => ['CURRENT_TIMESTAMP(6)', ' DEFAULT CURRENT_TIMESTAMP(6) '], + 'plain string' => ['abc', " DEFAULT 'abc' "], + 'embedded quote' => ["it's", " DEFAULT 'it''s' "], + 'timestamp-like literal' => ['current_timestamp_x', " DEFAULT 'current_timestamp_x' "], + 'trailing newline' => ["CURRENT_TIMESTAMP\n", " DEFAULT 'CURRENT_TIMESTAMP\n' "], + ]; + } + + #[DataProvider('defaults')] + public function testQuoteDefaultClause(?string $default, string $expected): void + { + $tables = (new \ReflectionClass(Tables::class))->newInstanceWithoutConstructor(); + $method = new \ReflectionMethod(Tables::class, 'quoteDefaultClause'); + + $this->assertSame($expected, $method->invoke($tables, $default)); + } +} diff --git a/tests/unit/xoopsLib/Xmf/Database/TablesSchemaTest.php b/tests/unit/xoopsLib/Xmf/Database/TablesSchemaTest.php new file mode 100644 index 000000000..35fc3dd0d --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/Database/TablesSchemaTest.php @@ -0,0 +1,236 @@ +newInstanceWithoutConstructor(); + foreach (['db' => $db, 'databaseName' => 'xoops', 'tables' => [], 'queue' => []] as $name => $value) { + $property = new \ReflectionProperty(Tables::class, $name); + $property->setValue($tables, $value); + } + return $tables; + } + + private static function itemsSchema(): SchemaFakeConnection + { + $db = new SchemaFakeConnection(); + $db->results['TABLES'] = [ + ['TABLE_NAME' => 'x_items', 'ENGINE' => 'InnoDB', 'CHARACTER_SET_NAME' => 'utf8mb4'], + ]; + $db->results['COLUMNS'] = [ + ['COLUMN_NAME' => 'id', 'COLUMN_TYPE' => 'int unsigned', 'IS_NULLABLE' => 'NO', + 'COLUMN_DEFAULT' => null, 'EXTRA' => 'auto_increment'], + ['COLUMN_NAME' => 'title', 'COLUMN_TYPE' => 'varchar(255)', 'IS_NULLABLE' => 'YES', + 'COLUMN_DEFAULT' => "it's", 'EXTRA' => ''], + ['COLUMN_NAME' => 'created', 'COLUMN_TYPE' => 'timestamp', 'IS_NULLABLE' => 'NO', + 'COLUMN_DEFAULT' => 'current_timestamp()', 'EXTRA' => 'DEFAULT_GENERATED on update CURRENT_TIMESTAMP'], + ]; + $db->results['STATISTICS'] = [ + ['INDEX_NAME' => 'PRIMARY', 'SEQ_IN_INDEX' => 1, 'NON_UNIQUE' => 0, 'COLUMN_NAME' => 'id', 'SUB_PART' => null], + ['INDEX_NAME' => 'idx_title', 'SEQ_IN_INDEX' => 1, 'NON_UNIQUE' => 1, 'COLUMN_NAME' => 'title', 'SUB_PART' => 20], + ['INDEX_NAME' => 'idx_title', 'SEQ_IN_INDEX' => 2, 'NON_UNIQUE' => 1, 'COLUMN_NAME' => 'created', 'SUB_PART' => null], + ['INDEX_NAME' => 'idx_func', 'SEQ_IN_INDEX' => 1, 'NON_UNIQUE' => 1, 'COLUMN_NAME' => null, 'SUB_PART' => null], + ]; + return $db; + } + + public function testUseTableLoadsColumnsAndKeys(): void + { + $tables = $this->tables(self::itemsSchema()); + + $this->assertTrue($tables->useTable('items')); + $definition = $tables->dumpTables()['items']; + + $this->assertSame('x_items', $definition['name']); + $this->assertSame('ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', $definition['options']); + $this->assertSame( + [ + ['name' => 'id', 'attributes' => ' int unsigned NOT NULL auto_increment'], + ['name' => 'title', 'attributes' => " varchar(255) DEFAULT 'it''s' "], + ['name' => 'created', 'attributes' => ' timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP() on update CURRENT_TIMESTAMP'], + ], + $definition['columns'] + ); + $this->assertSame( + [ + 'PRIMARY' => ['columns' => 'id', 'unique' => true], + 'idx_title' => ['columns' => 'title (20), created', 'unique' => false], + ], + $tables->getTableIndexes('items') + ); + $this->assertSame(' varchar(255) DEFAULT \'it\'\'s\' ', $tables->getColumnAttributes('items', 'TITLE')); + $this->assertFalse($tables->getColumnAttributes('items', 'missing')); + } + + public function testUseTableReturnsFalseForUnknownTable(): void + { + $db = new SchemaFakeConnection(); + $db->results['TABLES'] = []; + $tables = $this->tables($db); + + $this->assertFalse($tables->useTable('nothing')); + } + + public function testUseTableReportsQueryFailure(): void + { + $db = new SchemaFakeConnection(); + $db->fail = true; + $tables = $this->tables($db); + + $this->assertFalse($tables->useTable('items')); + $this->assertSame(['42S02', 1146, 'missing'], $tables->getLastError()); + $this->assertSame('42S02', $tables->getLastErrNo()); + } + + public function testAddIndexOnNewTableAndAlterOnExistingTable(): void + { + $tables = $this->tables(self::itemsSchema()); + $tables->addTable('fresh'); + $tables->addColumn('fresh', 'id', 'int NOT NULL'); + $tables->addIndex('idx_id', 'fresh', 'id', true); + + $this->assertSame(['columns' => '`id`', 'unique' => true], $tables->dumpTables()['fresh']['keys']['idx_id']); + + $tables->useTable('items'); + $tables->addIndex('idx_new', 'items', 'created'); + $this->assertContains('ALTER TABLE `x_items` ADD INDEX `idx_new` (`created`)', $tables->dumpQueue()); + } + + private function queueCreateTable(SchemaFakeConnection $db): Tables + { + $tables = $this->tables($db); + $tables->addTable('fresh'); + $tables->setTableOptions('fresh', 'ENGINE=InnoDB'); + $tables->addColumn('fresh', 'id', 'int NOT NULL'); + $tables->addPrimaryKey('fresh', 'id'); + return $tables; + } + + public function testExecuteQueueRendersCreateTableAndRunsItForced(): void + { + $db = self::itemsSchema(); + $db->affectedRows = 1; + $tables = $this->queueCreateTable($db); + + $this->assertTrue($tables->executeQueue(true)); + $this->assertTrue($db->forced); + $this->assertStringStartsWith('CREATE TABLE `x_fresh` (', end($db->queries)); + $this->assertStringContainsString('PRIMARY KEY (`id`)', end($db->queries)); + } + + public function testExecuteQueueAcceptsDdlThatAffectsNoRows(): void + { + $this->markTestIncomplete( + 'Existing 2.6 bug: Connection::safeQuery() returns null when a statement affects 0 rows, ' + . 'which every CREATE TABLE does, so Tables::executeQueue() reports a successful CREATE as failed.' + ); + $tables = $this->queueCreateTable(self::itemsSchema()); + + $this->assertTrue($tables->executeQueue(true)); + } + + public function testExecuteQueueStopsOnFailedStatement(): void + { + $db = self::itemsSchema(); + $tables = $this->tables($db); + $tables->useTable('items'); + $tables->dropColumn('items', 'title'); + $db->fail = true; + + $this->assertFalse($tables->executeQueue()); + $this->assertSame('42S02', $tables->getLastErrNo()); + } +} + +/** + * Minimal stand-in for Xoops\Core\Database\Connection as used by Tables + */ +class SchemaFakeConnection +{ + /** @var array>> rows keyed by INFORMATION_SCHEMA table */ + public $results = []; + public $queries = []; + public $fail = false; + public $force = false; + /** @var bool whether setForce(true) was called before the last statement */ + public $forced = false; + public $affectedRows = 0; + + public function prefix($table = '') + { + return 'x_' . $table; + } + + public function quote($value) + { + return "'" . str_replace("'", "''", (string) $value) . "'"; + } + + public function setForce($force) + { + $this->force = (bool) $force; + $this->forced = $this->forced || $this->force; + } + + public function query($sql) + { + $this->queries[] = $sql; + // like Connection::safeQuery(): force lasts for one statement, and a + // non-SELECT returns its affected-row count, or null when that is 0 + $this->force = false; + if ($this->fail) { + $result = false; + } elseif (stripos(ltrim($sql), 'select') !== 0) { + $result = $this->affectedRows ?: null; + } else { + // only x_items exists; every other table comes back empty + $rows = []; + if (strpos($sql, "TABLE_NAME = 'x_items'") !== false) { + foreach ($this->results as $schemaTable => $tableRows) { + if (strpos($sql, '`INFORMATION_SCHEMA`.`' . $schemaTable . '`') !== false) { + $rows = $tableRows; + } + } + } + $result = new SchemaFakeResult($rows); + } + return $result; + } + + public function errorInfo() + { + return ['42S02', 1146, 'missing']; + } + + public function errorCode() + { + return '42S02'; + } +} + +class SchemaFakeResult +{ + private $rows; + + public function __construct(array $rows) + { + $this->rows = $rows; + } + + public function fetchAssociative() + { + return array_shift($this->rows) ?? false; + } +} diff --git a/tests/unit/xoopsLib/Xmf/Database/TablesUnitTest.php b/tests/unit/xoopsLib/Xmf/Database/TablesUnitTest.php new file mode 100644 index 000000000..9af28a899 --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/Database/TablesUnitTest.php @@ -0,0 +1,228 @@ +setTables( + array( + 'demo' => array( + 'options' => 'ENGINE=InnoDB', + 'columns' => array( + array('attributes' => 'int NOT NULL'), + ), + ), + ) + ); + + $this->assertFalse($tables->callRenderTableCreate('demo')); + } + + public function testRenderTableCreateIncludesPrimaryAndUniqueKeys() + { + $tables = new TestableTables(); + $tables->setTables( + array( + 'demo' => array( + 'name' => 'xoops_demo', + 'options' => 'ENGINE=InnoDB', + 'columns' => array( + array('name' => 'id', 'attributes' => 'int NOT NULL'), + array('name' => 'title', 'attributes' => 'varchar(255) NOT NULL'), + ), + 'keys' => array( + 'PRIMARY' => array('columns' => '`id`'), + 'idx_title' => array('columns' => '`title`', 'unique' => true), + ), + ), + ) + ); + + $sql = $tables->callRenderTableCreate('demo', true); + + $this->assertStringContainsString('CREATE TABLE `xoops_demo`', $sql); + $this->assertStringContainsString('PRIMARY KEY (`id`)', $sql); + $this->assertStringContainsString('UNIQUE KEY idx_title (`title`)', $sql); + } + + public function testInsertSkipsMalformedColumnDefinitionWithWarning() + { + $tables = new TestableTables(); + $tables->setDb(new FakeTablesDatabase()); + $tables->setTables( + array( + 'demo' => array( + 'name' => 'xoops_demo', + 'columns' => array( + array('name' => 'id', 'attributes' => 'int NOT NULL'), + array('attributes' => 'varchar(255) NOT NULL'), + ), + ), + ) + ); + + $warning = $this->captureWarning(static function () use ($tables): void { + $tables->insert('demo', array('id' => 7)); + }); + + $this->assertStringContainsString('Skipping malformed column definition in Xmf\Database\Tables::insert', $warning); + $this->assertSame("INSERT INTO `xoops_demo` (`id`) VALUES('7')", $tables->dumpQueue()[0]); + } + + public function testUpdateSkipsMalformedColumnDefinitionWithWarning() + { + $tables = new TestableTables(); + $tables->setDb(new FakeTablesDatabase()); + $tables->setTables( + array( + 'demo' => array( + 'name' => 'xoops_demo', + 'columns' => array( + array('name' => 'title', 'attributes' => 'varchar(255) NOT NULL'), + array('attributes' => 'varchar(255) NOT NULL'), + ), + ), + ) + ); + + $warning = $this->captureWarning(static function () use ($tables): void { + $tables->update('demo', array('title' => 'Updated'), 'WHERE id = 1'); + }); + + $this->assertStringContainsString('Skipping malformed column definition in Xmf\Database\Tables::update', $warning); + $this->assertSame("UPDATE `xoops_demo` SET `title` = 'Updated' WHERE id = 1", $tables->dumpQueue()[0]); + } + + public function testInsertReturnsFalseWhenNoValidColumnsMatch() + { + $tables = new TestableTables(); + $tables->setDb(new FakeTablesDatabase()); + $tables->setTables( + array( + 'demo' => array( + 'name' => 'xoops_demo', + 'columns' => array( + array('name' => 'id', 'attributes' => 'int NOT NULL'), + ), + ), + ) + ); + + $this->assertFalse($tables->insert('demo', array('title' => 'Ignored'))); + $this->assertSame('No valid columns supplied for insert', $tables->getLastError()); + $this->assertSame(-1, $tables->getLastErrNo()); + $this->assertSame(array(), $tables->dumpQueue()); + } + + public function testUpdateReturnsFalseWhenNoValidColumnsMatch() + { + $tables = new TestableTables(); + $tables->setDb(new FakeTablesDatabase()); + $tables->setTables( + array( + 'demo' => array( + 'name' => 'xoops_demo', + 'columns' => array( + array('name' => 'id', 'attributes' => 'int NOT NULL'), + ), + ), + ) + ); + + $this->assertFalse($tables->update('demo', array('title' => 'Ignored'), 'WHERE id = 1')); + $this->assertSame('No valid columns supplied for update', $tables->getLastError()); + $this->assertSame(-1, $tables->getLastErrNo()); + $this->assertSame(array(), $tables->dumpQueue()); + } + + public function testQuoteDefaultClauseEscapesSingleQuotes() + { + $tables = new TestableTables(); + $result = $tables->callQuoteDefaultClause("O'Reilly"); + $this->assertSame(" DEFAULT 'O''Reilly' ", $result); + } + + public function testQuoteDefaultClauseNullReturnsEmpty() + { + $tables = new TestableTables(); + $result = $tables->callQuoteDefaultClause(null); + $this->assertSame('', $result); + } + + public function testQuoteDefaultClauseCurrentTimestamp() + { + $tables = new TestableTables(); + $result = $tables->callQuoteDefaultClause('CURRENT_TIMESTAMP'); + $this->assertSame(' DEFAULT CURRENT_TIMESTAMP ', $result); + } + + + + + + private function captureWarning(callable $callback): string + { + $warning = ''; + + set_error_handler(static function (int $errno, string $errstr) use (&$warning): bool { + $warning = $errstr; + return true; + }); + + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $warning; + } +} + +class TestableTables extends Tables +{ + public function __construct() + { + // skip the parent constructor: it needs a database connection + } + + public function setDb(object $db): void + { + $this->db = $db; + } + + public function setTables(array $tables): void + { + $this->tables = $tables; + $this->queue = array(); + } + + public function callRenderTableCreate(string $table, bool $prefixed = false): string|false + { + return $this->renderTableCreate($table, $prefixed); + } + + public function callQuoteDefaultClause(?string $default): string + { + return $this->quoteDefaultClause($default); + } + + /** + * @return \mysqli_result|bool + */ +} + +class FakeTablesDatabase +{ + public function quote(mixed $value): string + { + return "'" . addslashes((string) $value) . "'"; + } +} diff --git a/tests/unit/xoopsLib/Xmf/FilterInputTest.php b/tests/unit/xoopsLib/Xmf/FilterInputTest.php index 77d6cc7ce..93e773cc7 100644 --- a/tests/unit/xoopsLib/Xmf/FilterInputTest.php +++ b/tests/unit/xoopsLib/Xmf/FilterInputTest.php @@ -110,4 +110,141 @@ public function testCleanVarTypes($value, $type, $expected) { $this->assertSame($expected, $this->object->cleanVar($value, $type)); } + + // ported from XMF 1.3.2 tests/unit + /** + * Tag names that are empty or do not start with a letter are rejected by the + * tag-name regex, so they are stripped. Locks in the removal of the redundant + * "!$tagName" test in filterTags(). + */ + public function testCleanStripsMalformedTagNames() + { + // "<>" is not recognised as a tag at all, so it is left untouched. + $this->assertEquals('<>', FilterInput::clean('<>', 'string')); + // A digit-led tag name fails the regex and is stripped. + $this->assertEquals('', FilterInput::clean('<0>', 'string')); + $this->assertEquals('hello', FilterInput::clean('<0>hello', 'string')); + $this->assertEquals('text', FilterInput::clean('<0img src=x>text', 'string')); + // A well-formed tag is still stripped, content preserved. + $this->assertEquals('keep', FilterInput::clean('keep', 'string')); + } + + public function testWeburlRejectsProtocolRelativeUrl() + { + $result = FilterInput::clean('//evil.example/path', 'WEBURL'); + $this->assertSame('', $result); + } + + public function testWeburlAllowsHttpUrl() + { + $result = FilterInput::clean('http://example.com/page', 'WEBURL'); + $this->assertSame('http://example.com/page', $result); + } + + public function testWeburlAllowsRelativeUrl() + { + $result = FilterInput::clean('/local/path', 'WEBURL'); + $this->assertSame('/local/path', $result); + } + + public function testWeburlRejectsProtocolRelativeUrlWithLeadingWhitespace() + { + $result = FilterInput::clean(' //evil.example/path', 'WEBURL'); + $this->assertSame('', $result); + } + + public function testWeburlRejectsJavascriptScheme() + { + $result = FilterInput::clean('javascript:alert(1)', 'WEBURL'); + $this->assertSame('', $result); + } + + public function testHexEntityDecode() + { + // A = 'A', B = 'B', C = 'C' + $input = 'ABC'; + $result = FilterInput::clean($input, 'string'); + $this->assertStringContainsString('ABC', $result); + } + + public function testDecimalEntityDecode() + { + // A = 'A', B = 'B' + $input = 'AB'; + $result = FilterInput::clean($input, 'string'); + $this->assertStringContainsString('AB', $result); + } + + public function testEmptyTagIsKeptWithoutGrowing() + { + // "<>" stays as text; it used to re-append the remainder on every pass, so remove() never settled + $this->assertSame('a<>b', FilterInput::clean('a<>b', 'string')); + $this->assertSame('x<> y<>', FilterInput::clean('x<> y<>', 'string')); + $this->assertSame('<>keep', FilterInput::clean('<>keep', 'string')); + } + + public function testIncompleteTagDoesNotSurviveAsTag() + { + // text before a nested or missing ">" is kept, but without a "<" that would open a tag + $this->assertSame('img src="<>" onerror=alert(1)>', FilterInput::clean('', 'string')); + $this->assertSame('img src=x onerror=alert(1) ', FilterInput::clean('', 'string')); + $this->assertSame('ximg src=x onerror=alert(1) ', FilterInput::clean('xassertSame('<>', FilterInput::clean('<>