From ac14239510eba89926be50cd827ef16a1d4c0648 Mon Sep 17 00:00:00 2001 From: blaipr Date: Wed, 19 Aug 2026 00:59:08 +0200 Subject: [PATCH] fix: hold profile and group names unique in the database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UserProfile` and `UserGroup` each refuse a name that is already taken — `UPPER(:name) = UPPER(name)` in `checkDuplicatedOnAdd()` — and neither table had a unique index at all. That check is a SELECT followed by an INSERT, so two requests arriving together both find nothing and both insert, with nothing underneath to stop the second: two groups called Admins, and no way to tell which one a permission refers to. A plain unique index is the application's rule exactly, because `name` collates utf8mb4_unicode_ci — 'Admins' and 'ADMINS' collide, which is what the UPPER() comparison was asking for. Confirmed against a server, with the whole schema reloaded to check nothing else objects. This completes the sweep the User login started. Every `checkDuplicated*` in the repositories now has an index behind it: Category, Client and Tag by hash, PublicLink by hash and by account, AuthToken by (token, actionId) — a composite because that is its rule — User by login, and these two by name. The schema test covers all five through one data provider, so a table added later with a PHP-only uniqueness rule is caught by adding a row to it. Schema only, as in #806 and #825: which of two identically named groups is the real one, and what becomes of the permissions pointing at the other, is an administrator's decision rather than a migration's. --- schemas/dbstructure.sql | 6 ++- .../Database/SchemaEnforcesIdentityTest.php | 43 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/schemas/dbstructure.sql b/schemas/dbstructure.sql index e2c618623..d36a30ade 100644 --- a/schemas/dbstructure.sql +++ b/schemas/dbstructure.sql @@ -651,7 +651,8 @@ CREATE TABLE `UserGroup` `id` smallint(5) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `description` varchar(255) DEFAULT NULL, - PRIMARY KEY (`id`) + PRIMARY KEY (`id`), + UNIQUE KEY `uk_UserGroup_01` (`name`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; @@ -691,7 +692,8 @@ CREATE TABLE `UserProfile` `id` smallint(5) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(45) NOT NULL, `profile` blob NOT NULL, - PRIMARY KEY (`id`) + PRIMARY KEY (`id`), + UNIQUE KEY `uk_UserProfile_01` (`name`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; diff --git a/tests/Unit/Infrastructure/Database/SchemaEnforcesIdentityTest.php b/tests/Unit/Infrastructure/Database/SchemaEnforcesIdentityTest.php index 619a95dad..612555e15 100644 --- a/tests/Unit/Infrastructure/Database/SchemaEnforcesIdentityTest.php +++ b/tests/Unit/Infrastructure/Database/SchemaEnforcesIdentityTest.php @@ -145,6 +145,49 @@ public function aLoginIsHeldUniqueOnItsOwn(): void ); } + /** + * A name the application holds unique is unique in the database too. + * + * `UserProfile` and `UserGroup` each refuse a name that is already taken — + * `UPPER(:name) = UPPER(name)` in `checkDuplicatedOnAdd()` — and neither table had any unique + * index at all. That check is a SELECT followed by an INSERT, so two requests arriving + * together both find nothing and both insert, and there was nothing underneath to stop the + * second: two groups called Admins, and no way to tell which one a permission refers to. + * + * A plain unique index is the application's rule exactly, because `name` collates + * `utf8mb4_unicode_ci` — 'Admins' and 'ADMINS' collide, which is what `UPPER()` was asking + * for. + * + * @return array + */ + public static function uniqueNameProvider(): array + { + return [ + 'UserProfile' => ['UserProfile', 'name'], + 'UserGroup' => ['UserGroup', 'name'], + 'Category' => ['Category', 'hash'], + 'Client' => ['Client', 'hash'], + 'Tag' => ['Tag', 'hash'], + ]; + } + + #[Test] + #[DataProvider('uniqueNameProvider')] + public function whatTheApplicationHoldsUniqueTheDatabaseHoldsUnique(string $table, string $column): void + { + self::assertMatchesRegularExpression( + sprintf('/UNIQUE KEY\s+`[^`]+`\s*\(`%s`\)/', preg_quote($column, '/')), + self::tableDefinition($table), + sprintf( + '%s.%s is refused as a duplicate by the repository, with a SELECT before the ' + . 'INSERT that two concurrent requests both pass. Only a unique index stops the ' + . 'second one.', + $table, + $column + ) + ); + } + /** * Every table is utf8mb4, so text somebody actually types can be stored. *