diff --git a/phpmyfaq/src/phpMyFAQ/Http/RateLimiter.php b/phpmyfaq/src/phpMyFAQ/Http/RateLimiter.php new file mode 100644 index 0000000000..320e77cca3 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Http/RateLimiter.php @@ -0,0 +1,138 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-09 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Http; + +use phpMyFAQ\Configuration; +use phpMyFAQ\Database; + +final class RateLimiter +{ + /** @var array */ + private array $headersStorage = []; + + /** @var array */ + public array $headers { + get => $this->headersStorage; + } + + public function __construct( + private readonly Configuration $configuration, + ) { + } + + /** + * Checks if a request should be allowed for the given key. + */ + public function check(string $key, int $limit, int $intervalSeconds): bool + { + $limit = max(1, $limit); + $intervalSeconds = max(1, $intervalSeconds); + + $db = $this->configuration->getDb(); + $escapedKey = $db->escape($key); + $table = Database::getTablePrefix() . 'faqrate_limits'; + + $now = time(); + $windowStart = (int) (floor($now / $intervalSeconds) * $intervalSeconds); + $windowReset = $windowStart + $intervalSeconds; + + // Attempt INSERT for a new window (atomic — either succeeds or fails on duplicate key) + $insertQuery = sprintf( + "INSERT INTO %s (rate_key, window_start, requests, created) VALUES ('%s', %d, 1, %s)", + $table, + $escapedKey, + $windowStart, + $db->now(), + ); + + if ($db->query($insertQuery) === false) { + // Row already exists — atomically increment using the DB's current value + $updateQuery = sprintf( + "UPDATE %s SET requests = requests + 1 WHERE rate_key = '%s' AND window_start = %d", + $table, + $escapedKey, + $windowStart, + ); + + if ($db->query($updateQuery) === false) { + // DB write failed — deny the request (fail-closed) + $this->headersStorage = [ + 'X-RateLimit-Limit' => $limit, + 'X-RateLimit-Remaining' => 0, + 'X-RateLimit-Reset' => $windowReset, + 'Retry-After' => max(1, $windowReset - $now), + ]; + + return false; + } + } + + // Read the authoritative post-increment count + $selectQuery = sprintf( + "SELECT requests FROM %s WHERE rate_key = '%s' AND window_start = %d", + $table, + $escapedKey, + $windowStart, + ); + $result = $db->query($selectQuery); + $row = $result !== false ? $db->fetchObject($result) : false; + + if (!is_object($row) || !isset($row->requests)) { + // Cannot read authoritative count — deny the request (fail-closed) + $this->headersStorage = [ + 'X-RateLimit-Limit' => $limit, + 'X-RateLimit-Remaining' => 0, + 'X-RateLimit-Reset' => $windowReset, + 'Retry-After' => max(1, $windowReset - $now), + ]; + + return false; + } + + $currentRequests = (int) $row->requests; + + if ($currentRequests > $limit) { + $this->headersStorage = [ + 'X-RateLimit-Limit' => $limit, + 'X-RateLimit-Remaining' => 0, + 'X-RateLimit-Reset' => $windowReset, + 'Retry-After' => max(1, $windowReset - $now), + ]; + + return false; + } + + $this->headersStorage = [ + 'X-RateLimit-Limit' => $limit, + 'X-RateLimit-Remaining' => max(0, $limit - $currentRequests), + 'X-RateLimit-Reset' => $windowReset, + ]; + + return true; + } + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database.php b/phpmyfaq/src/phpMyFAQ/Instance/Database.php index b98d12d5d8..64fff262a3 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database.php @@ -37,58 +37,6 @@ class Database */ private static ?DriverInterface $driver = null; - /** - * DROP TABLE statements. - */ - private array $dropTableStmts = [ - 'DROP TABLE %sfaqadminlog', - 'DROP TABLE %sfaqattachment', - 'DROP TABLE %sfaqattachment_file', - 'DROP TABLE %sfaqapi_keys', - 'DROP TABLE %sfaqoauth_clients', - 'DROP TABLE %sfaqoauth_scopes', - 'DROP TABLE %sfaqoauth_access_tokens', - 'DROP TABLE %sfaqoauth_refresh_tokens', - 'DROP TABLE %sfaqoauth_auth_codes', - 'DROP TABLE %sfaqbackup', - 'DROP TABLE %sfaqcaptcha', - 'DROP TABLE %sfaqcategories', - 'DROP TABLE %sfaqcategoryrelations', - 'DROP TABLE %sfaqcategory_group', - 'DROP TABLE %sfaqcategory_user', - 'DROP TABLE %sfaqchanges', - 'DROP TABLE %sfaqchat_messages', - 'DROP TABLE %sfaqcomments', - 'DROP TABLE %sfaqconfig', - 'DROP TABLE %sfaqdata', - 'DROP TABLE %sfaqdata_revisions', - 'DROP TABLE %sfaqdata_group', - 'DROP TABLE %sfaqdata_tags', - 'DROP TABLE %sfaqdata_user', - 'DROP TABLE %sfaqglossary', - 'DROP TABLE %sfaqgroup', - 'DROP TABLE %sfaqgroup_right', - 'DROP TABLE %sfaqinstances', - 'DROP TABLE %sfaqinstances_config', - 'DROP TABLE %sfaqmigrations', - 'DROP TABLE %sfaqnews', - 'DROP TABLE %sfaqpush_subscriptions', - 'DROP TABLE %sfaqquestions', - 'DROP TABLE %sfaqright', - 'DROP TABLE %sfaqsearches', - 'DROP TABLE %sfaqseo', - 'DROP TABLE %sfaqsessions', - 'DROP TABLE %sfaqstopwords', - 'DROP TABLE %sfaqtags', - 'DROP TABLE %sfaquser', - 'DROP TABLE %sfaquserdata', - 'DROP TABLE %sfaquserlogin', - 'DROP TABLE %sfaquser_group', - 'DROP TABLE %sfaquser_right', - 'DROP TABLE %sfaqvisits', - 'DROP TABLE %sfaqvoting', - ]; - /** * Constructor. */ @@ -173,20 +121,4 @@ public static function createTenantDatabase(Configuration $configuration, string $type, )); } - - /** - * Executes all DROP TABLE statements. - */ - public function dropTables(string $prefix = ''): bool - { - foreach ($this->dropTableStmts as $dropTableStmt) { - $result = $this->configuration->getDb()->query(sprintf($dropTableStmt, $prefix)); - - if (!$result) { - return false; - } - } - - return true; - } } diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Installation/DatabaseSchema.php b/phpmyfaq/src/phpMyFAQ/Setup/Installation/DatabaseSchema.php index e2ae0d9ce6..e1a3a4cd54 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Installation/DatabaseSchema.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Installation/DatabaseSchema.php @@ -76,6 +76,7 @@ public function getAllTables(): array 'faqcustompages' => $this->faqcustompages(), 'faqquestions' => $this->faqquestions(), 'faqright' => $this->faqright(), + 'faqrate_limits' => $this->faqrateLimits(), 'faqsearches' => $this->faqsearches(), 'faqseo' => $this->faqseo(), 'faqsessions' => $this->faqsessions(), @@ -581,6 +582,17 @@ public function faqright(): TableBuilder ->primaryKey('right_id'); } + public function faqrateLimits(): TableBuilder + { + return new TableBuilder($this->dialect) + ->table('faqrate_limits') + ->varchar('rate_key', 255, false) + ->integer('window_start', false) + ->integer('requests', false, 0) + ->timestamp('created') + ->primaryKey(['rate_key', 'window_start']); + } + public function faqsearches(): TableBuilder { return new TableBuilder($this->dialect) diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php b/phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php index cf756439c6..02854d9577 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php @@ -257,6 +257,8 @@ private static function buildDefaultConfig(): array 'api.apiClientToken' => '', 'api.onlyActiveFaqs' => 'true', 'api.onlyActiveCategories' => 'true', + 'api.rateLimit.requests' => '100', + 'api.rateLimit.interval' => '3600', 'translation.provider' => 'none', 'translation.googleApiKey' => '', 'translation.deeplApiKey' => '', diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php index c7aa0cf1bf..b3b7250e79 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php @@ -37,7 +37,7 @@ public function getDependencies(): array public function getDescription(): string { - return 'Admin log hash columns, custom pages, chat messages, translation config, API keys, OAuth2 tables'; + return 'Admin log hash columns, custom pages, chat messages, translation config, API rate limiting, API keys, OAuth2 tables'; } public function up(OperationRecorder $recorder): void @@ -207,6 +207,8 @@ public function up(OperationRecorder $recorder): void $recorder->addConfig('api.onlyActiveCategories', 'true'); $recorder->addConfig('api.onlyPublicQuestions', 'true'); $recorder->addConfig('api.ignoreOrphanedFaqs', 'true'); + $recorder->addConfig('api.rateLimit.requests', '100'); + $recorder->addConfig('api.rateLimit.interval', '3600'); // Translation service configuration $recorder->addConfig('translation.provider', 'none'); @@ -520,6 +522,55 @@ public function up(OperationRecorder $recorder): void $recorder->addConfig('push.vapidPrivateKey', ''); $recorder->addConfig('push.vapidSubject', ''); + // Create API rate-limit table + if ($this->isMySql()) { + $recorder->addSql(sprintf( + 'CREATE TABLE IF NOT EXISTS %sfaqrate_limits ( + rate_key VARCHAR(255) NOT NULL, + window_start INT(11) NOT NULL, + requests INT(11) NOT NULL DEFAULT 0, + created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (rate_key, window_start) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB', + $this->tablePrefix, + ), 'Create API rate limits table (MySQL)'); + } elseif ($this->isPostgreSql()) { + $recorder->addSql(sprintf( + 'CREATE TABLE IF NOT EXISTS %sfaqrate_limits ( + rate_key VARCHAR(255) NOT NULL, + window_start INTEGER NOT NULL, + requests INTEGER NOT NULL DEFAULT 0, + created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (rate_key, window_start) + )', + $this->tablePrefix, + ), 'Create API rate limits table (PostgreSQL)'); + } elseif ($this->isSqlite()) { + $recorder->addSql(sprintf('CREATE TABLE IF NOT EXISTS %sfaqrate_limits ( + rate_key VARCHAR(255) NOT NULL, + window_start INTEGER NOT NULL, + requests INTEGER NOT NULL DEFAULT 0, + created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (rate_key, window_start) + )', $this->tablePrefix), 'Create API rate limits table (SQLite)'); + } elseif ($this->isSqlServer()) { + $recorder->addSql( + sprintf( + "IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'%sfaqrate_limits') AND type = 'U') " + . 'CREATE TABLE %sfaqrate_limits ( + rate_key VARCHAR(255) NOT NULL, + window_start INT NOT NULL, + requests INT NOT NULL DEFAULT 0, + created DATETIME NOT NULL DEFAULT GETDATE(), + PRIMARY KEY (rate_key, window_start) + )', + $this->tablePrefix, + $this->tablePrefix, + ), + 'Create API rate limits table (SQL Server)', + ); + } + // Create API keys table if ($this->isMySql()) { $recorder->addSql(sprintf( diff --git a/phpmyfaq/src/phpMyFAQ/System.php b/phpmyfaq/src/phpMyFAQ/System.php index 23597504e4..9841aed5e8 100644 --- a/phpmyfaq/src/phpMyFAQ/System.php +++ b/phpmyfaq/src/phpMyFAQ/System.php @@ -446,18 +446,4 @@ public function createHashes(): string return json_encode($hashes, JSON_THROW_ON_ERROR); } - - /** - * Drops all given tables - * - * @param array $queries - */ - public function dropTables(array $queries): void - { - if ($this->databaseDriver instanceof DatabaseDriver) { - foreach ($queries as $query) { - $this->databaseDriver->query($query); - } - } - } } diff --git a/phpmyfaq/src/services.php b/phpmyfaq/src/services.php index 26558b2c6b..19ca2f8842 100644 --- a/phpmyfaq/src/services.php +++ b/phpmyfaq/src/services.php @@ -59,6 +59,7 @@ use phpMyFAQ\Faq\Statistics; use phpMyFAQ\Forms; use phpMyFAQ\Glossary; +use phpMyFAQ\Http\RateLimiter; use phpMyFAQ\Helper\CategoryHelper; use phpMyFAQ\Helper\FaqHelper; use phpMyFAQ\Helper\QuestionHelper; @@ -321,6 +322,10 @@ service('phpmyfaq.configuration'), ]); + $services->set('phpmyfaq.http.rate-limiter', RateLimiter::class)->args([ + service('phpmyfaq.configuration'), + ]); + $services->set('phpmyfaq.helper.category-helper', CategoryHelper::class); $services->set('phpmyfaq.helper.faq', FaqHelper::class)->args([ diff --git a/phpmyfaq/translations/language_en.php b/phpmyfaq/translations/language_en.php index c8d1a3e55e..88b9bafb02 100644 --- a/phpmyfaq/translations/language_en.php +++ b/phpmyfaq/translations/language_en.php @@ -1672,4 +1672,7 @@ $PMF_LANG['msgVapidKeysGenerated'] = 'VAPID keys have been generated successfully.'; $PMF_LANG['msgVapidKeysError'] = 'Failed to generate VAPID keys.'; +$LANG_CONF['api.rateLimit.requests'] = ['input', 'API rate limit', 'Standard: 100 requests']; +$LANG_CONF['api.rateLimit.interval'] = ['input', 'API rate limit interval in seconds', 'Standard: 3600 seconds']; + return $PMF_LANG; diff --git a/tests/phpMyFAQ/Auth/AuthDatabaseTest.php b/tests/phpMyFAQ/Auth/AuthDatabaseTest.php index e09a9c98c4..628e2b9d2c 100644 --- a/tests/phpMyFAQ/Auth/AuthDatabaseTest.php +++ b/tests/phpMyFAQ/Auth/AuthDatabaseTest.php @@ -16,14 +16,25 @@ protected function setUp(): void { $this->authDatabase = new AuthDatabase(Configuration::getConfigurationInstance()); $this->authDatabase->getEncryptionContainer('sha1'); + + // Clean up leftover users from previous runs + foreach (['testUser', 'testUser2'] as $login) { + try { + $this->authDatabase->delete($login); + } catch (Exception) { + // Ignore — user may not exist + } + } } protected function tearDown(): void { - try { - $this->authDatabase->delete('testUser'); - } catch (Exception $e) { - // Ignore + foreach (['testUser', 'testUser2'] as $login) { + try { + $this->authDatabase->delete($login); + } catch (Exception) { + // Ignore — user may not exist + } } } diff --git a/tests/phpMyFAQ/Http/RateLimiterTest.php b/tests/phpMyFAQ/Http/RateLimiterTest.php new file mode 100644 index 0000000000..96f93d7627 --- /dev/null +++ b/tests/phpMyFAQ/Http/RateLimiterTest.php @@ -0,0 +1,154 @@ +configuration = $this->createMock(Configuration::class); + $this->db = $this->createMock(DatabaseDriver::class); + $this->configuration->method('getDb')->willReturn($this->db); + } + + public function testCheckAllowsRequestAndSetsHeaders(): void + { + $this->db->method('escape')->willReturnCallback(static fn (string $value): string => $value); + $this->db->method('now')->willReturn('CURRENT_TIMESTAMP'); + $this->db->method('query')->willReturnCallback(static function (string $query): mixed { + // INSERT succeeds (new window), then SELECT returns the new row + if (str_starts_with($query, 'INSERT')) { + return true; + } + + if (str_starts_with($query, 'SELECT')) { + return 'select-result'; + } + + return true; + }); + $this->db->method('fetchObject')->with('select-result')->willReturn((object) ['requests' => 1]); + + $limiter = new RateLimiter($this->configuration); + $allowed = $limiter->check('127.0.0.1', 5, 60); + + $this->assertTrue($allowed); + $headers = $limiter->headers; + $this->assertSame(5, $headers['X-RateLimit-Limit']); + $this->assertSame(4, $headers['X-RateLimit-Remaining']); + $this->assertArrayHasKey('X-RateLimit-Reset', $headers); + } + + public function testCheckDeniesRequestWhenLimitIsExceeded(): void + { + $this->db->method('escape')->willReturnCallback(static fn (string $value): string => $value); + $this->db->method('now')->willReturn('CURRENT_TIMESTAMP'); + $this->db->method('query')->willReturnCallback(static function (string $query): mixed { + // INSERT fails (row exists), UPDATE increments, SELECT returns over-limit count + if (str_starts_with($query, 'INSERT')) { + return false; + } + + if (str_starts_with($query, 'SELECT')) { + return 'select-result'; + } + + return true; + }); + $this->db->method('fetchObject')->with('select-result')->willReturn((object) ['requests' => 4]); + + $limiter = new RateLimiter($this->configuration); + $allowed = $limiter->check('api-key-1', 3, 60); + + $this->assertFalse($allowed); + $headers = $limiter->headers; + $this->assertSame(0, $headers['X-RateLimit-Remaining']); + $this->assertArrayHasKey('Retry-After', $headers); + } + + public function testCheckAllowsRequestAfterAtomicIncrement(): void + { + $this->db->method('escape')->willReturnCallback(static fn (string $value): string => $value); + $this->db->method('now')->willReturn('CURRENT_TIMESTAMP'); + $this->db->method('query')->willReturnCallback(static function (string $query): mixed { + // INSERT fails (row exists), UPDATE increments, SELECT returns within-limit count + if (str_starts_with($query, 'INSERT')) { + return false; + } + + if (str_starts_with($query, 'SELECT')) { + return 'select-result'; + } + + return true; + }); + $this->db->method('fetchObject')->with('select-result')->willReturn((object) ['requests' => 2]); + + $limiter = new RateLimiter($this->configuration); + $allowed = $limiter->check('api-key-1', 5, 60); + + $this->assertTrue($allowed); + $headers = $limiter->headers; + $this->assertSame(5, $headers['X-RateLimit-Limit']); + $this->assertSame(3, $headers['X-RateLimit-Remaining']); + } + + public function testCheckDeniesRequestWhenUpdateFails(): void + { + $this->db->method('escape')->willReturnCallback(static fn (string $value): string => $value); + $this->db->method('now')->willReturn('CURRENT_TIMESTAMP'); + $this->db->method('query')->willReturnCallback(static function (string $query): mixed { + // INSERT fails (row exists), UPDATE also fails (DB error) + if (str_starts_with($query, 'INSERT')) { + return false; + } + + return false; + }); + + $limiter = new RateLimiter($this->configuration); + $allowed = $limiter->check('api-key-1', 5, 60); + + $this->assertFalse($allowed); + $headers = $limiter->headers; + $this->assertSame(0, $headers['X-RateLimit-Remaining']); + $this->assertArrayHasKey('Retry-After', $headers); + } + + public function testCheckDeniesRequestWhenSelectFails(): void + { + $this->db->method('escape')->willReturnCallback(static fn (string $value): string => $value); + $this->db->method('now')->willReturn('CURRENT_TIMESTAMP'); + $this->db->method('query')->willReturnCallback(static function (string $query): mixed { + // INSERT succeeds, but SELECT fails + if (str_starts_with($query, 'INSERT')) { + return true; + } + + return false; + }); + + $limiter = new RateLimiter($this->configuration); + $allowed = $limiter->check('api-key-1', 5, 60); + + $this->assertFalse($allowed); + $headers = $limiter->headers; + $this->assertSame(0, $headers['X-RateLimit-Remaining']); + $this->assertArrayHasKey('Retry-After', $headers); + } +} diff --git a/tests/phpMyFAQ/Instance/DatabaseTest.php b/tests/phpMyFAQ/Instance/DatabaseTest.php index 1fe7a2277b..f8da34e1f5 100644 --- a/tests/phpMyFAQ/Instance/DatabaseTest.php +++ b/tests/phpMyFAQ/Instance/DatabaseTest.php @@ -42,36 +42,6 @@ public function testGetInstance(): void $this->assertInstanceOf(Database\DriverInterface::class, $instance); } - /** - * @throws Exception - * @throws \PHPUnit\Framework\MockObject\Exception - */ - public function testDropTables(): void - { - $dbMock = $this->createStub(DatabaseDriver::class); - $this->configuration->method('getDb')->willReturn($dbMock); - - $dbMock->method('query')->willReturn(true); - - $database = Database::factory($this->configuration, 'mysqli'); - $result = $database->dropTables('test_'); - - $this->assertTrue($result); - } - - public function testDropTablesWithFailure(): void - { - $dbMock = $this->createStub(DatabaseDriver::class); - $this->configuration->method('getDb')->willReturn($dbMock); - - $dbMock->method('query')->willReturn(false); - - $database = Database::factory($this->configuration, 'mysqli'); - $result = $database->dropTables('test_'); - - $this->assertFalse($result); - } - public function testCreateTenantDatabaseThrowsForInvalidDatabaseName(): void { $dbMock = $this->createMock(DatabaseDriver::class); diff --git a/tests/phpMyFAQ/Setup/Installation/DatabaseSchemaTest.php b/tests/phpMyFAQ/Setup/Installation/DatabaseSchemaTest.php index c338e9ab59..4940991d20 100644 --- a/tests/phpMyFAQ/Setup/Installation/DatabaseSchemaTest.php +++ b/tests/phpMyFAQ/Setup/Installation/DatabaseSchemaTest.php @@ -12,7 +12,7 @@ class DatabaseSchemaTest extends TestCase { - private const EXPECTED_TABLE_COUNT = 50; + private const EXPECTED_TABLE_COUNT = 51; /** * @return array @@ -45,6 +45,7 @@ public function testGetTableNamesReturnsCorrectNames(DialectInterface $dialect): $this->assertContains('faqdata', $names); $this->assertContains('faquser', $names); $this->assertContains('faqconfig', $names); + $this->assertContains('faqrate_limits', $names); $this->assertContains('faqapi_keys', $names); $this->assertContains('faqoauth_clients', $names); $this->assertContains('faqoauth_access_tokens', $names); diff --git a/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php b/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php index 0e3bf8b1c1..dedc3e4123 100644 --- a/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php +++ b/tests/phpMyFAQ/Setup/Installation/SchemaInstallerTest.php @@ -50,7 +50,7 @@ public function testDryRunCollectsAllSql(DialectInterface $dialect): void $createTableCount++; } } - $this->assertEquals(50, $createTableCount, 'Should generate CREATE TABLE for all 50 tables'); + $this->assertEquals(51, $createTableCount, 'Should generate CREATE TABLE for all 51 tables'); } #[DataProvider('dialectProvider')] diff --git a/tests/phpMyFAQ/Setup/Migration/Versions/Migration420AlphaTest.php b/tests/phpMyFAQ/Setup/Migration/Versions/Migration420AlphaTest.php index 78f64826b1..6c5a80003c 100644 --- a/tests/phpMyFAQ/Setup/Migration/Versions/Migration420AlphaTest.php +++ b/tests/phpMyFAQ/Setup/Migration/Versions/Migration420AlphaTest.php @@ -79,4 +79,48 @@ public function testUpAddsOAuthStorageTablesSql(): void $this->assertTrue($foundOauthTable, 'Expected at least one SQL statement containing "faqoauth_clients"'); } + + public function testUpAddsRateLimitConfigEntries(): void + { + $recorder = $this->createMock(OperationRecorder::class); + $addedConfigKeys = []; + + $recorder->method('addConfig')->willReturnCallback( + static function (string $name, string $value) use (&$addedConfigKeys, $recorder): OperationRecorder { + $addedConfigKeys[] = $name; + + return $recorder; + }, + ); + + $recorder->method('addSql')->willReturn($recorder); + $recorder->method('grantPermission')->willReturn($recorder); + + $this->migration->up($recorder); + + $this->assertContains('api.rateLimit.requests', $addedConfigKeys); + $this->assertContains('api.rateLimit.interval', $addedConfigKeys); + } + + public function testUpAddsFaqrateLimitsTableSql(): void + { + $recorder = $this->createMock(OperationRecorder::class); + $foundRateLimitSql = false; + + $recorder->method('addSql')->willReturnCallback( + static function (string $sql, string $description) use (&$foundRateLimitSql, $recorder): OperationRecorder { + if (str_contains($sql, 'faqrate_limits')) { + $foundRateLimitSql = true; + } + + return $recorder; + }, + ); + $recorder->method('addConfig')->willReturn($recorder); + $recorder->method('grantPermission')->willReturn($recorder); + + $this->migration->up($recorder); + + $this->assertTrue($foundRateLimitSql, 'Expected migration SQL creating faqrate_limits table.'); + } }