diff --git a/sql/updates/2.0.0.sql b/sql/updates/2.0.0.sql index 1ba4186..08726c5 100755 --- a/sql/updates/2.0.0.sql +++ b/sql/updates/2.0.0.sql @@ -1,10 +1,18 @@ -- Update DB schema to version 2.0.0 +-- If an update stops halfway (e.g. a timeout converting a large table), the +-- schema version stays at the previous one, and the next attempt runs this +-- file again from the start: every statement has to work on tables which +-- already have its change. Statements which can't be written that way in +-- MySQL (ADD COLUMN, ADD INDEX) are marked "CAN FAIL" for Joomla's installer +-- (Joomla 4.2+), which then goes on if they fail. Checked by +-- tests/Unit/UpdateSqlTest.php and tests/Integration/UpdateSqlTest.php. + -- number of requests rejected because of a block, and the time of the -- latest one (issue #219); existing blocks start with no data -ALTER TABLE `#__bfstop_bannedip` ADD COLUMN attempts int unsigned NOT NULL DEFAULT 0; +ALTER TABLE `#__bfstop_bannedip` ADD COLUMN attempts int unsigned NOT NULL DEFAULT 0 /** CAN FAIL **/; -ALTER TABLE `#__bfstop_bannedip` ADD COLUMN last_attempt datetime NULL DEFAULT NULL; +ALTER TABLE `#__bfstop_bannedip` ADD COLUMN last_attempt datetime NULL DEFAULT NULL /** CAN FAIL **/; -- use utf8mb4 like Joomla's own tables; with utf8 (3-byte), a failed login @@ -18,7 +26,7 @@ ALTER TABLE `#__bfstop_unblock` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4 ALTER TABLE `#__bfstop_unblock_token` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- the user an unblock link was sent to, to limit the links per user -ALTER TABLE `#__bfstop_unblock_token` ADD COLUMN username varchar(150) NULL DEFAULT NULL; +ALTER TABLE `#__bfstop_unblock_token` ADD COLUMN username varchar(150) NULL DEFAULT NULL /** CAN FAIL **/; ALTER TABLE `#__bfstop_allowlist` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; @@ -60,10 +68,10 @@ INSERT INTO `#__bfstop_username_stats` (username, attempts, first_attempt, last_ ON DUPLICATE KEY UPDATE attempts=attempts; -- speeds up the per-username (account-level) throttle query -ALTER TABLE `#__bfstop_failedlogin` ADD INDEX username_logtime (username, logtime); +ALTER TABLE `#__bfstop_failedlogin` ADD INDEX username_logtime (username, logtime) /** CAN FAIL **/; -- every request looks up the visitor's address in the block and allow list; -- addresses and networks are stored in canonical form (the plugin brings -- existing entries into it once, IpHelper::canonicalEntry()) -ALTER TABLE `#__bfstop_bannedip` ADD INDEX ipaddress (ipaddress); -ALTER TABLE `#__bfstop_allowlist` ADD INDEX ipaddress (ipaddress); +ALTER TABLE `#__bfstop_bannedip` ADD INDEX ipaddress (ipaddress) /** CAN FAIL **/; +ALTER TABLE `#__bfstop_allowlist` ADD INDEX ipaddress (ipaddress) /** CAN FAIL **/; diff --git a/tests/Integration/UpdateSqlTest.php b/tests/Integration/UpdateSqlTest.php new file mode 100644 index 0000000..bd39c08 --- /dev/null +++ b/tests/Integration/UpdateSqlTest.php @@ -0,0 +1,64 @@ +queryValue('SELECT version_id FROM #__schemas WHERE extension_id='.(int) $extensionId); + } + + public function testUpdateCanRunAgainOnTablesItAlreadyChanged() + { + if ($this->db->getServerType() !== 'mysql') + { + $this->markTestSkipped('PostgreSQL is supported from 2.0.0 on, there is no update SQL for it yet'); + } + $extensionId = (int) $this->queryValue("SELECT extension_id FROM #__extensions WHERE type='plugin' AND element='bfstop'"); + $original = $this->schemaVersion($extensionId); + $this->assertNotNull($original); + try + { + $this->db->setQuery("UPDATE #__schemas SET version_id='1.4.3' WHERE extension_id=".$extensionId); + $this->db->execute(); + + $root = dirname(__DIR__, 2); + $manifest = simplexml_load_file($root.'/bfstop.xml'); + $installer = new Installer(); + $installer->setDatabase($this->db); + $installer->setPath('extension_root', $root); + $result = $installer->parseSchemaUpdates($manifest->update->schemas, $extensionId); + + $this->assertNotFalse($result, 'an update SQL statement failed, see the "Update" log of the Joomla site'); + $versions = array_map(function ($file) { return basename($file, '.sql'); }, + glob($root.'/sql/updates/*.sql')); + usort($versions, 'version_compare'); + $this->assertSame(end($versions), $this->schemaVersion($extensionId)); + } + finally + { + $this->db->setQuery('UPDATE #__schemas SET version_id='.$this->db->quote($original). + ' WHERE extension_id='.$extensionId); + $this->db->execute(); + } + } +} diff --git a/tests/Unit/UpdateSqlTest.php b/tests/Unit/UpdateSqlTest.php new file mode 100644 index 0000000..31b1c15 --- /dev/null +++ b/tests/Unit/UpdateSqlTest.php @@ -0,0 +1,86 @@ +=')) + { + $files[basename($file)] = array($file); + } + } + return $files; + } + + private static function statements($file) + { + $sql = preg_replace('/^\s*--.*$/m', '', file_get_contents($file)); + return array_filter(array_map('trim', explode(';', $sql)), 'strlen'); + } + + #[DataProvider('updateFiles')] + public function testStatementsWhichCanOnlyRunOnceAreMarkedCanFail($file) + { + foreach (self::statements($file) as $statement) + { + if (preg_match(self::NotRepeatable, $statement)) + { + $this->assertStringEndsWith(self::Marker, $statement, + basename($file).": fails if run again, mark it CAN FAIL: $statement"); + } + } + } + + #[DataProvider('updateFiles')] + public function testTablesAreCreatedOnlyIfTheyDontExist($file) + { + foreach (self::statements($file) as $statement) + { + if (preg_match('/^CREATE\s+TABLE\b/i', $statement)) + { + $this->assertMatchesRegularExpression('/^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i', $statement, + basename($file)); + } + } + } + + public function testTheMarkerIsWhereJoomlaLooksForIt() + { + // Installer::parseSchemaUpdates() only recognises it right before the + // semicolon which ends the statement + $marked = 0; + foreach (self::updateFiles() as list($file)) + { + $sql = preg_replace('/^\s*--.*$/m', '', file_get_contents($file)); + $marked += substr_count($sql, self::Marker); + $this->assertSame(substr_count($sql, self::Marker), substr_count($sql, ' '.self::Marker.';'), basename($file)); + } + $this->assertGreaterThan(0, $marked); + } +}