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. *