From 38377e2bb4a198d4526b3725d8ab3dab02551b35 Mon Sep 17 00:00:00 2001 From: Logan Lindquist Land Date: Thu, 17 Sep 2026 17:59:16 -0500 Subject: [PATCH] Add database constraints for chat data --- .github/workflows/ci.yml | 6 +- README.md | 1 + app/Commands/SeedMessages.php | 23 +- app/Config/Validation.php | 7 +- app/Controllers/Api/V1/ChannelsController.php | 2 +- app/Controllers/Api/V1/MessagesController.php | 2 +- ...09-17-000000_tighten_users_constraints.php | 121 +++++++ ...17-010000_tighten_messages_constraints.php | 327 ++++++++++++++++++ app/Helpers/ValidationHelper.php | 15 +- app/Models/ChatModel.php | 111 +++--- bench/README.md | 2 +- bench/lib/auth.js | 2 +- composer.json | 2 +- docs/database-schema.md | 260 ++++++-------- docs/public-api.md | 9 +- .../ChannelModelIntegrationTest.php | 2 +- .../Tests/Integration/ChatIntegrationTest.php | 2 +- .../DatabaseConstraintsIntegrationTest.php | 175 ++++++++++ .../MessageSearchIntegrationTest.php | 41 ++- .../Integration/ProfileIntegrationTest.php | 28 +- .../Integration/RetentionIntegrationTest.php | 6 +- .../Integration/SeedMessagesCommandTest.php | 15 +- .../UserProfileRepositoryIntegrationTest.php | 8 +- .../WebSocketFlowIntegrationTest.php | 4 +- tests/Tests/Unit/ChatModelTest.php | 3 +- tests/_support/IntegrationTestCase.php | 11 +- 26 files changed, 923 insertions(+), 262 deletions(-) create mode 100644 app/Database/Migrations/2026-09-17-000000_tighten_users_constraints.php create mode 100644 app/Database/Migrations/2026-09-17-010000_tighten_messages_constraints.php create mode 100644 tests/Tests/Integration/DatabaseConstraintsIntegrationTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 757dbe8..6c0e966 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,8 +78,8 @@ jobs: - name: Run Composer audit run: composer audit - mysql-search: - name: MySQL 8 message search + mysql-integration: + name: MySQL 8 integration if: github.event_name == 'pull_request' runs-on: ubuntu-latest @@ -120,5 +120,5 @@ jobs: - name: Install Composer dependencies run: composer install --prefer-dist --no-progress --no-interaction - - name: Run message search integration tests against MySQL + - name: Run database integration tests against MySQL run: composer test:integration:mysql diff --git a/README.md b/README.md index ad17df6..a31744e 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ See: - `docs/user-profiles.md` (profile settings, avatars, themes, and WebSocket presence) - `docs/channels.md` (public channels, private DMs, APIs, WebSocket routing, and migration operations) - `docs/operations.md` (data retention, message archiving, batching, backups, and export storage) +- `docs/database-schema.md` (core relationships, named constraints, and cross-database migration behavior) ## Troubleshooting diff --git a/app/Commands/SeedMessages.php b/app/Commands/SeedMessages.php index fa56256..d8912df 100644 --- a/app/Commands/SeedMessages.php +++ b/app/Commands/SeedMessages.php @@ -80,10 +80,15 @@ private function replaceBenchmarkData(BaseConnection $db, int $count): void { $db->transException(true)->transStart(); - $this->seedUsers($db); + $userIds = $this->seedUsers($db); + $general = $db->table('channels')->select('id')->where('slug', 'general')->get()->getRowArray(); + if (! is_array($general)) { + throw new \LogicException('The #general channel is missing. Run database migrations.'); + } + $channelId = (int) $general['id']; $messages = $db->table('messages'); - $messages->like('user', 'k6-user-', 'after')->delete(); + $messages->whereIn('user_id', array_values($userIds))->delete(); for ($offset = 0; $offset < $count; $offset += self::BATCH_SIZE) { $batchSize = min(self::BATCH_SIZE, $count - $offset); @@ -91,8 +96,10 @@ private function replaceBenchmarkData(BaseConnection $db, int $count): void for ($index = 0; $index < $batchSize; $index++) { $sequence = $offset + $index + 1; + $username = self::usernameFor($sequence); $rows[] = [ - 'user' => self::usernameFor($sequence), + 'user_id' => $userIds[$username], + 'channel_id' => $channelId, 'msg' => sprintf('Benchmark message %08d', $sequence), 'time' => 1_700_000_000 + $sequence, ]; @@ -104,11 +111,13 @@ private function replaceBenchmarkData(BaseConnection $db, int $count): void $db->transComplete(); } - private function seedUsers(BaseConnection $db): void + /** @return array */ + private function seedUsers(BaseConnection $db): array { $users = $db->table('users'); $password = password_hash(self::BENCHMARK_PASSWORD, PASSWORD_DEFAULT); $now = date('Y-m-d H:i:s'); + $ids = []; for ($sequence = 1; $sequence <= self::BENCHMARK_USER_COUNT; $sequence++) { $username = self::usernameFor($sequence); @@ -124,14 +133,18 @@ private function seedUsers(BaseConnection $db): void 'username' => $username, 'created_at' => $now, ]); + $ids[$username] = (int) $db->insertID(); } else { $users->where('id', $existing['id'])->update($row); + $ids[$username] = (int) $existing['id']; } } + + return $ids; } private static function usernameFor(int $sequence): string { - return sprintf('k6-user-%03d', (($sequence - 1) % self::BENCHMARK_USER_COUNT) + 1); + return sprintf('k6_user_%03d', (($sequence - 1) % self::BENCHMARK_USER_COUNT) + 1); } } diff --git a/app/Config/Validation.php b/app/Config/Validation.php index 424e69b..35abc17 100644 --- a/app/Config/Validation.php +++ b/app/Config/Validation.php @@ -65,12 +65,11 @@ class Validation extends BaseConfig */ public array $registration = [ 'username' => [ - 'rules' => 'required|min_length[3]|max_length[50]|alpha_numeric|is_unique[users.username]', + 'rules' => 'required|max_length[30]|regex_match[/^[A-Za-z0-9_]{3,30}$/]|is_unique[users.username]', 'errors' => [ 'required' => 'Username is required', - 'min_length' => 'Username must be at least 3 characters long', - 'max_length' => 'Username cannot exceed 50 characters', - 'alpha_numeric' => 'Username can only contain alphanumeric characters', + 'max_length' => 'Username cannot exceed 30 characters', + 'regex_match' => 'Username must be 3-30 letters, numbers, or underscores', 'is_unique' => 'Username is already taken', ], ], diff --git a/app/Controllers/Api/V1/ChannelsController.php b/app/Controllers/Api/V1/ChannelsController.php index 1807845..6ae593e 100644 --- a/app/Controllers/Api/V1/ChannelsController.php +++ b/app/Controllers/Api/V1/ChannelsController.php @@ -231,7 +231,7 @@ public function searchMessages(int $channelId): ResponseInterface } $textInput = $this->optionalQueryString('text', 500); - $userInput = $this->optionalQueryString('user', 255); + $userInput = $this->optionalQueryString('user', 30); $fromInput = $this->optionalTimestamp('from'); $toInput = $this->optionalTimestamp('to'); $page = $this->positiveIntegerQuery('page', 1, PHP_INT_MAX); diff --git a/app/Controllers/Api/V1/MessagesController.php b/app/Controllers/Api/V1/MessagesController.php index b4a6945..79cc9e9 100644 --- a/app/Controllers/Api/V1/MessagesController.php +++ b/app/Controllers/Api/V1/MessagesController.php @@ -70,7 +70,7 @@ public function listXml(): ResponseInterface private function validatedSearchInput(): array { $text = $this->optionalString('text', 500); - $user = $this->optionalString('user', 255); + $user = $this->optionalString('user', 30); $from = $this->optionalUnsignedInteger('from'); $to = $this->optionalUnsignedInteger('to'); $page = $this->positiveInteger('page', 1, PHP_INT_MAX); diff --git a/app/Database/Migrations/2026-09-17-000000_tighten_users_constraints.php b/app/Database/Migrations/2026-09-17-000000_tighten_users_constraints.php new file mode 100644 index 0000000..2d35882 --- /dev/null +++ b/app/Database/Migrations/2026-09-17-000000_tighten_users_constraints.php @@ -0,0 +1,121 @@ +database(); + + if ($db->getPlatform() === 'MySQLi') { + $users = $db->prefixTable('users'); + $db->query( + "ALTER TABLE {$users} + ADD CONSTRAINT " . self::USERNAME_CHECK . " CHECK ( + CHAR_LENGTH(username) BETWEEN 3 AND 30 + AND username REGEXP '^[A-Za-z0-9_]+$' + ), + ADD CONSTRAINT " . self::PASSWORD_CHECK . " CHECK ( + password LIKE '\$2y\$%' OR password LIKE '\$argon2%' + )", + ); + + return; + } + + $this->rebuildSqliteUsers(true); + } + + public function down(): void + { + $db = $this->database(); + + if ($db->getPlatform() === 'MySQLi') { + $users = $db->prefixTable('users'); + $db->query("ALTER TABLE {$users} DROP CHECK " . self::PASSWORD_CHECK); + $db->query("ALTER TABLE {$users} DROP CHECK " . self::USERNAME_CHECK); + + return; + } + + $this->rebuildSqliteUsers(false); + } + + private function rebuildSqliteUsers(bool $withChecks): void + { + $db = $this->database(); + $users = $db->prefixTable('users'); + $replacement = $db->prefixTable('users_constraint_rebuild'); + $usernameCheck = $withChecks + ? ' CONSTRAINT ' . self::USERNAME_CHECK . " CHECK ( + length(username) BETWEEN 3 AND 30 + AND username NOT GLOB '*[^A-Za-z0-9_]*' + )" + : ''; + $passwordCheck = $withChecks + ? ' CONSTRAINT ' . self::PASSWORD_CHECK . " CHECK ( + password LIKE '\$2y\$%' OR password LIKE '\$argon2%' + )" + : ''; + + $db->disableForeignKeyChecks(); + + try { + $db->query("DROP TABLE IF EXISTS {$replacement}"); + $db->query( + "CREATE TABLE {$replacement} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(50) NOT NULL UNIQUE{$usernameCheck}, + email VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL{$passwordCheck}, + created_at DATETIME NULL, + updated_at DATETIME NULL, + display_name VARCHAR(100) NULL, + avatar_path VARCHAR(255) NULL, + theme VARCHAR(16) NOT NULL DEFAULT 'system', + notification_prefs TEXT NULL, + presence VARCHAR(16) NOT NULL DEFAULT 'offline', + last_seen_at DATETIME NULL + )", + ); + $db->query( + "INSERT INTO {$replacement} ( + id, username, email, password, created_at, updated_at, + display_name, avatar_path, theme, notification_prefs, + presence, last_seen_at + ) + SELECT + id, username, email, password, created_at, updated_at, + display_name, avatar_path, theme, notification_prefs, + presence, last_seen_at + FROM {$users}", + ); + $db->query("DROP TABLE {$users}"); + $db->query("ALTER TABLE {$replacement} RENAME TO {$users}"); + } finally { + $db->enableForeignKeyChecks(); + } + + $violations = $db->query('PRAGMA foreign_key_check')->getResultArray(); + if ($violations !== []) { + throw new LogicException('User constraint migration left invalid foreign keys.'); + } + } + + private function database(): BaseConnection + { + if (! $this->db instanceof BaseConnection) { + throw new LogicException('User constraint migrations require a database connection.'); + } + + return $this->db; + } +} diff --git a/app/Database/Migrations/2026-09-17-010000_tighten_messages_constraints.php b/app/Database/Migrations/2026-09-17-010000_tighten_messages_constraints.php new file mode 100644 index 0000000..26a7c6b --- /dev/null +++ b/app/Database/Migrations/2026-09-17-010000_tighten_messages_constraints.php @@ -0,0 +1,327 @@ +assertLegacyMessagesAreValid(); + + if ($this->database()->getPlatform() === 'MySQLi') { + $this->migrateMysqlUp(); + + return; + } + + $this->rebuildSqliteMessages(true); + } + + public function down(): void + { + if ($this->database()->getPlatform() === 'MySQLi') { + $this->migrateMysqlDown(); + + return; + } + + $this->rebuildSqliteMessages(false); + } + + private function migrateMysqlUp(): void + { + $db = $this->database(); + $messages = $db->prefixTable('messages'); + $archived = $db->prefixTable('archived_messages'); + $users = $db->prefixTable('users'); + + $db->query("ALTER TABLE {$messages} DROP INDEX idx_messages_fulltext"); + $db->query("ALTER TABLE {$messages} DROP INDEX idx_messages_time_user"); + $db->query("ALTER TABLE {$messages} ADD user_id INT(11) UNSIGNED NULL AFTER channel_id"); + $db->query("UPDATE {$messages} AS message LEFT JOIN {$users} AS user ON user.username = message.user SET message.user_id = user.id"); + $db->query("ALTER TABLE {$messages} DROP COLUMN user"); + $db->query( + "ALTER TABLE {$messages} + ADD INDEX idx_messages_user_id_time (user_id, time, id), + ADD FULLTEXT INDEX idx_messages_fulltext (msg), + ADD CONSTRAINT " . self::LIVE_USER_FK . " FOREIGN KEY (user_id) REFERENCES {$users}(id) ON DELETE SET NULL ON UPDATE CASCADE, + ADD CONSTRAINT " . self::LIVE_MESSAGE_CHECK . ' CHECK (CHAR_LENGTH(msg) BETWEEN 1 AND 500), + ADD CONSTRAINT ' . self::LIVE_TIME_CHECK . ' CHECK (time > 0)', + ); + + $db->query("ALTER TABLE {$archived} DROP INDEX user_id"); + $db->query("ALTER TABLE {$archived} ADD user_id INT(11) UNSIGNED NULL AFTER channel_id"); + $db->query("UPDATE {$archived} AS message LEFT JOIN {$users} AS user ON user.username = message.user SET message.user_id = user.id"); + $db->query("ALTER TABLE {$archived} DROP COLUMN user"); + $db->query( + "ALTER TABLE {$archived} + ADD INDEX idx_archived_messages_user_id (user_id, id), + ADD CONSTRAINT " . self::ARCHIVE_USER_FK . " FOREIGN KEY (user_id) REFERENCES {$users}(id) ON DELETE SET NULL ON UPDATE CASCADE, + ADD CONSTRAINT " . self::ARCHIVE_MESSAGE_CHECK . ' CHECK (CHAR_LENGTH(msg) BETWEEN 1 AND 500), + ADD CONSTRAINT ' . self::ARCHIVE_TIME_CHECK . ' CHECK (time > 0)', + ); + } + + private function migrateMysqlDown(): void + { + $db = $this->database(); + $messages = $db->prefixTable('messages'); + $archived = $db->prefixTable('archived_messages'); + $users = $db->prefixTable('users'); + + $db->query("ALTER TABLE {$messages} DROP INDEX idx_messages_fulltext"); + $db->query("ALTER TABLE {$messages} ADD user VARCHAR(255) CHARACTER SET latin1 NULL AFTER channel_id"); + $db->query("UPDATE {$messages} AS message LEFT JOIN {$users} AS user ON user.id = message.user_id SET message.user = COALESCE(user.username, '[deleted]')"); + $db->query( + "ALTER TABLE {$messages} + DROP FOREIGN KEY " . self::LIVE_USER_FK . ', + DROP INDEX idx_messages_user_id_time, + DROP CHECK ' . self::LIVE_MESSAGE_CHECK . ', + DROP CHECK ' . self::LIVE_TIME_CHECK . ', + DROP COLUMN user_id, + MODIFY user VARCHAR(255) CHARACTER SET latin1 NOT NULL, + ADD INDEX user (user), + ADD INDEX idx_messages_time_user (time, user), + ADD FULLTEXT INDEX idx_messages_fulltext (user, msg)', + ); + + $db->query("ALTER TABLE {$archived} ADD user VARCHAR(255) CHARACTER SET latin1 NULL AFTER channel_id"); + $db->query("UPDATE {$archived} AS message LEFT JOIN {$users} AS user ON user.id = message.user_id SET message.user = COALESCE(user.username, '[deleted]')"); + $db->query( + "ALTER TABLE {$archived} + DROP FOREIGN KEY " . self::ARCHIVE_USER_FK . ', + DROP INDEX idx_archived_messages_user_id, + DROP CHECK ' . self::ARCHIVE_MESSAGE_CHECK . ', + DROP CHECK ' . self::ARCHIVE_TIME_CHECK . ', + DROP COLUMN user_id, + MODIFY user VARCHAR(255) CHARACTER SET latin1 NOT NULL, + ADD INDEX user_id (user, id)', + ); + } + + private function rebuildSqliteMessages(bool $withConstraints): void + { + $db = $this->database(); + $messages = $db->prefixTable('messages'); + $archived = $db->prefixTable('archived_messages'); + $users = $db->prefixTable('users'); + $liveReplacement = $db->prefixTable('messages_constraint_rebuild'); + $archiveReplacement = $db->prefixTable('archived_messages_constraint_rebuild'); + + $this->dropSqliteSearch(); + $db->disableForeignKeyChecks(); + + try { + $db->query("DROP TABLE IF EXISTS {$liveReplacement}"); + $db->query("DROP TABLE IF EXISTS {$archiveReplacement}"); + + if ($withConstraints) { + $this->createSqliteNormalizedTables($liveReplacement, $archiveReplacement); + $db->query( + "INSERT INTO {$liveReplacement} (id, channel_id, user_id, msg, time) + SELECT message.id, message.channel_id, user.id, message.msg, message.time + FROM {$messages} AS message + LEFT JOIN {$users} AS user ON user.username = message.user", + ); + $db->query( + "INSERT INTO {$archiveReplacement} (id, channel_id, user_id, msg, time, archived_at) + SELECT message.id, message.channel_id, user.id, message.msg, message.time, message.archived_at + FROM {$archived} AS message + LEFT JOIN {$users} AS user ON user.username = message.user", + ); + } else { + $this->createSqliteLegacyTables($liveReplacement, $archiveReplacement); + $db->query( + "INSERT INTO {$liveReplacement} (id, channel_id, user, msg, time) + SELECT message.id, message.channel_id, COALESCE(user.username, '[deleted]'), message.msg, message.time + FROM {$messages} AS message + LEFT JOIN {$users} AS user ON user.id = message.user_id", + ); + $db->query( + "INSERT INTO {$archiveReplacement} (id, channel_id, user, msg, time, archived_at) + SELECT message.id, message.channel_id, COALESCE(user.username, '[deleted]'), message.msg, message.time, message.archived_at + FROM {$archived} AS message + LEFT JOIN {$users} AS user ON user.id = message.user_id", + ); + } + + $db->query("DROP TABLE {$archived}"); + $db->query("DROP TABLE {$messages}"); + $db->query("ALTER TABLE {$liveReplacement} RENAME TO {$messages}"); + $db->query("ALTER TABLE {$archiveReplacement} RENAME TO {$archived}"); + $this->createSqliteIndexes($withConstraints); + $this->createSqliteSearch($withConstraints); + } finally { + $db->enableForeignKeyChecks(); + } + + $violations = $db->query('PRAGMA foreign_key_check')->getResultArray(); + if ($violations !== []) { + throw new LogicException('Message constraint migration left invalid foreign keys.'); + } + } + + private function createSqliteNormalizedTables(string $messages, string $archived): void + { + $channels = $this->database()->prefixTable('channels'); + $users = $this->database()->prefixTable('users'); + $generalId = $this->generalChannelId(); + $this->database()->query( + "CREATE TABLE {$messages} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL DEFAULT {$generalId}, + user_id INTEGER NULL, + msg TEXT NOT NULL CONSTRAINT " . self::LIVE_MESSAGE_CHECK . ' CHECK (length(msg) BETWEEN 1 AND 500), + time INTEGER NOT NULL CONSTRAINT ' . self::LIVE_TIME_CHECK . " CHECK (time > 0), + CONSTRAINT fk_messages_channel FOREIGN KEY (channel_id) REFERENCES {$channels}(id) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT " . self::LIVE_USER_FK . " FOREIGN KEY (user_id) REFERENCES {$users}(id) ON DELETE SET NULL ON UPDATE CASCADE + )", + ); + $this->database()->query( + "CREATE TABLE {$archived} ( + id INTEGER PRIMARY KEY, + channel_id INTEGER NOT NULL, + user_id INTEGER NULL, + msg TEXT NOT NULL CONSTRAINT " . self::ARCHIVE_MESSAGE_CHECK . ' CHECK (length(msg) BETWEEN 1 AND 500), + time INTEGER NOT NULL CONSTRAINT ' . self::ARCHIVE_TIME_CHECK . " CHECK (time > 0), + archived_at DATETIME NOT NULL, + CONSTRAINT fk_archived_messages_channel FOREIGN KEY (channel_id) REFERENCES {$channels}(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT " . self::ARCHIVE_USER_FK . " FOREIGN KEY (user_id) REFERENCES {$users}(id) ON DELETE SET NULL ON UPDATE CASCADE + )", + ); + } + + private function createSqliteLegacyTables(string $messages, string $archived): void + { + $channels = $this->database()->prefixTable('channels'); + $generalId = $this->generalChannelId(); + $this->database()->query( + "CREATE TABLE {$messages} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL DEFAULT {$generalId}, + user VARCHAR(255) NOT NULL, + msg TEXT NOT NULL, + time INTEGER NOT NULL DEFAULT 0 + )", + ); + $this->database()->query( + "CREATE TABLE {$archived} ( + id INTEGER PRIMARY KEY, + channel_id INTEGER NOT NULL, + user VARCHAR(255) NOT NULL, + msg TEXT NOT NULL, + time INTEGER NOT NULL DEFAULT 0, + archived_at DATETIME NOT NULL, + CONSTRAINT fk_archived_messages_channel FOREIGN KEY (channel_id) REFERENCES {$channels}(id) ON DELETE CASCADE ON UPDATE CASCADE + )", + ); + } + + private function createSqliteIndexes(bool $normalized): void + { + $db = $this->database(); + $messages = $db->prefixTable('messages'); + $archived = $db->prefixTable('archived_messages'); + $db->query("CREATE INDEX idx_messages_time ON {$messages} (time)"); + $db->query("CREATE INDEX idx_messages_channel_time ON {$messages} (channel_id, time, id)"); + $db->query("CREATE INDEX idx_archived_messages_channel_time ON {$archived} (channel_id, time, id)"); + + if ($normalized) { + $db->query("CREATE INDEX idx_messages_user_id ON {$messages} (user_id)"); + $db->query("CREATE INDEX idx_messages_time_user_id ON {$messages} (time, user_id)"); + $db->query("CREATE INDEX idx_archived_messages_user_id ON {$archived} (user_id, id)"); + } else { + $db->query("CREATE INDEX idx_messages_user ON {$messages} (user)"); + $db->query("CREATE INDEX idx_messages_time_user ON {$messages} (time, user)"); + $db->query("CREATE INDEX idx_archived_messages_user ON {$archived} (user, id)"); + } + } + + private function dropSqliteSearch(): void + { + $db = $this->database(); + $search = $db->prefixTable('messages_fts'); + $triggerPrefix = $db->getPrefix() . 'messages_fts'; + + foreach (['update', 'delete', 'insert'] as $suffix) { + $db->query("DROP TRIGGER IF EXISTS {$triggerPrefix}_{$suffix}"); + } + $db->query("DROP TABLE IF EXISTS {$search}"); + } + + private function createSqliteSearch(bool $normalized): void + { + $db = $this->database(); + $messages = $db->prefixTable('messages'); + $search = $db->prefixTable('messages_fts'); + $triggerPrefix = $db->getPrefix() . 'messages_fts'; + + if ($normalized) { + $db->query("CREATE VIRTUAL TABLE {$search} USING fts5(msg, content='{$messages}', content_rowid='id')"); + $db->query("INSERT INTO {$search}(rowid, msg) SELECT id, msg FROM {$messages}"); + $db->query("CREATE TRIGGER {$triggerPrefix}_insert AFTER INSERT ON {$messages} BEGIN INSERT INTO {$search}(rowid, msg) VALUES (new.id, new.msg); END"); + $db->query("CREATE TRIGGER {$triggerPrefix}_delete AFTER DELETE ON {$messages} BEGIN INSERT INTO {$search}({$search}, rowid, msg) VALUES ('delete', old.id, old.msg); END"); + $db->query("CREATE TRIGGER {$triggerPrefix}_update AFTER UPDATE ON {$messages} BEGIN INSERT INTO {$search}({$search}, rowid, msg) VALUES ('delete', old.id, old.msg); INSERT INTO {$search}(rowid, msg) VALUES (new.id, new.msg); END"); + + return; + } + + $db->query("CREATE VIRTUAL TABLE {$search} USING fts5(user, msg, content='{$messages}', content_rowid='id')"); + $db->query("INSERT INTO {$search}(rowid, user, msg) SELECT id, user, msg FROM {$messages}"); + $db->query("CREATE TRIGGER {$triggerPrefix}_insert AFTER INSERT ON {$messages} BEGIN INSERT INTO {$search}(rowid, user, msg) VALUES (new.id, new.user, new.msg); END"); + $db->query("CREATE TRIGGER {$triggerPrefix}_delete AFTER DELETE ON {$messages} BEGIN INSERT INTO {$search}({$search}, rowid, user, msg) VALUES ('delete', old.id, old.user, old.msg); END"); + $db->query("CREATE TRIGGER {$triggerPrefix}_update AFTER UPDATE ON {$messages} BEGIN INSERT INTO {$search}({$search}, rowid, user, msg) VALUES ('delete', old.id, old.user, old.msg); INSERT INTO {$search}(rowid, user, msg) VALUES (new.id, new.user, new.msg); END"); + } + + private function database(): BaseConnection + { + if (! $this->db instanceof BaseConnection) { + throw new LogicException('Message constraint migrations require a database connection.'); + } + + return $this->db; + } + + private function assertLegacyMessagesAreValid(): void + { + $db = $this->database(); + $length = $db->getPlatform() === 'MySQLi' ? 'CHAR_LENGTH(msg)' : 'length(msg)'; + + foreach (['messages', 'archived_messages'] as $table) { + $invalid = $db->table($table) + ->groupStart() + ->where("{$length} <", 1, false) + ->orWhere("{$length} >", 500, false) + ->orWhere('time <=', 0) + ->groupEnd() + ->countAllResults(); + + if ($invalid > 0) { + throw new LogicException( + "Cannot tighten {$table}: {$invalid} row(s) have an empty or over-500-character message, or a non-positive timestamp.", + ); + } + } + } + + private function generalChannelId(): int + { + $row = $this->database()->table('channels')->select('id')->where('slug', 'general')->get()->getRowArray(); + if (! is_array($row)) { + throw new LogicException('The #general channel is required for the message constraint migration.'); + } + + return (int) $row['id']; + } +} diff --git a/app/Helpers/ValidationHelper.php b/app/Helpers/ValidationHelper.php index 3e15ace..5b3dcaa 100644 --- a/app/Helpers/ValidationHelper.php +++ b/app/Helpers/ValidationHelper.php @@ -75,12 +75,16 @@ public static function getUsernameRules( bool $required = true, bool $unique = true, int $minLength = 3, - int $maxLength = 50 + int $maxLength = 30 ): array { $rules = [ - 'rules' => 'alpha_numeric', + 'rules' => sprintf('regex_match[/^[A-Za-z0-9_]{%d,%d}$/]', max(1, $minLength), max(1, $maxLength)), 'errors' => [ - 'alpha_numeric' => 'Username can only contain alphanumeric characters', + 'regex_match' => sprintf( + 'Username must be %d-%d letters, numbers, or underscores', + max(1, $minLength), + max(1, $maxLength), + ), ], ]; @@ -89,11 +93,6 @@ public static function getUsernameRules( $rules['errors']['required'] = 'Username is required'; } - if ($minLength > 0) { - $rules['rules'] .= '|min_length[' . $minLength . ']'; - $rules['errors']['min_length'] = 'Username must be at least ' . $minLength . ' characters long'; - } - if ($maxLength > 0) { $rules['rules'] .= '|max_length[' . $maxLength . ']'; $rules['errors']['max_length'] = 'Username cannot exceed ' . $maxLength . ' characters'; diff --git a/app/Models/ChatModel.php b/app/Models/ChatModel.php index 3aa1404..d8e164d 100644 --- a/app/Models/ChatModel.php +++ b/app/Models/ChatModel.php @@ -4,6 +4,7 @@ use App\Contracts\ChatRepository; use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Model; use CodeIgniter\Validation\ValidationInterface; @@ -22,7 +23,7 @@ class ChatModel extends Model implements ChatRepository { protected $table = 'messages'; protected $primaryKey = 'id'; - protected $allowedFields = ['user', 'msg', 'time', 'channel_id']; + protected $allowedFields = ['user_id', 'msg', 'time', 'channel_id']; /** * Cache key for messages @@ -79,15 +80,16 @@ public function getMsgPaginated(int $page = 1, int $perPage = 10, ?int $channelI if ($result === null) { $channelId ??= $this->generalChannelId(); // Get total count for pagination - $totalCount = $this->where('channel_id', $channelId)->countAllResults(); + $totalCount = $this->db->table('messages')->where('channel_id', $channelId)->countAllResults(); // Use time index for ordering instead of id // This is more efficient for chat applications where time-based ordering is natural - $messages = $this->where('channel_id', $channelId) - ->orderBy('time', 'DESC') - ->limit($perPage, $offset) - ->get() - ->getResultArray(); + $messages = $this->liveMessageBuilder() + ->where('messages.channel_id', $channelId) + ->orderBy('messages.time', 'DESC') + ->limit($perPage, $offset) + ->get() + ->getResultArray(); // Calculate total pages $totalPages = ceil($totalCount / $perPage); @@ -147,7 +149,8 @@ public function getMessageHistory(int $channelId, ?int $before = null, int $limi $limit = max(1, min(100, $limit)); $messages = $this->db->prefixTable('messages'); $archived = $this->db->prefixTable('archived_messages'); - $cursor = $before === null ? '' : ' AND id < ?'; + $users = $this->db->prefixTable('users'); + $cursor = $before === null ? '' : ' AND message.id < ?'; $bindings = [$channelId]; if ($before !== null) { $bindings[] = $before; @@ -160,9 +163,13 @@ public function getMessageHistory(int $channelId, ?int $before = null, int $limi $rows = $this->db->query( "SELECT id, channel_id, user, msg, time, archived FROM ( - SELECT id, channel_id, user, msg, time, 0 AS archived FROM {$messages} WHERE channel_id = ?{$cursor} + SELECT message.id, message.channel_id, COALESCE(author.username, '[deleted]') AS user, message.msg, message.time, 0 AS archived + FROM {$messages} AS message LEFT JOIN {$users} AS author ON author.id = message.user_id + WHERE message.channel_id = ?{$cursor} UNION ALL - SELECT id, channel_id, user, msg, time, 1 AS archived FROM {$archived} WHERE channel_id = ?{$cursor} + SELECT message.id, message.channel_id, COALESCE(author.username, '[deleted]') AS user, message.msg, message.time, 1 AS archived + FROM {$archived} AS message LEFT JOIN {$users} AS author ON author.id = message.user_id + WHERE message.channel_id = ?{$cursor} ) AS channel_history ORDER BY id DESC LIMIT ?", $bindings, )->getResultArray(); @@ -209,6 +216,7 @@ public function searchMessages( $user = $user !== null ? trim($user) : null; $messagesTable = $this->db->prefixTable($this->table); + $usersTable = $this->db->prefixTable('users'); $bindings = []; $conditions = []; $channelId ??= $this->generalChannelId(); @@ -217,20 +225,20 @@ public function searchMessages( if ($this->db->getPlatform() === 'SQLite3' && $text !== null && $text !== '') { $searchTable = $this->db->prefixTable('messages_fts'); - $fromClause = "{$messagesTable} AS messages INNER JOIN {$searchTable} ON {$searchTable}.rowid = messages.id"; + $fromClause = "{$messagesTable} AS messages INNER JOIN {$searchTable} ON {$searchTable}.rowid = messages.id LEFT JOIN {$usersTable} AS users ON users.id = messages.user_id"; $conditions[] = "{$searchTable} MATCH ?"; $bindings[] = $this->toSqliteFtsQuery($text); } else { - $fromClause = "{$messagesTable} AS messages"; + $fromClause = "{$messagesTable} AS messages LEFT JOIN {$usersTable} AS users ON users.id = messages.user_id"; if ($this->db->getPlatform() === 'MySQLi' && $text !== null && $text !== '') { - $conditions[] = 'MATCH(messages.user, messages.msg) AGAINST (? IN NATURAL LANGUAGE MODE)'; + $conditions[] = 'MATCH(messages.msg) AGAINST (? IN NATURAL LANGUAGE MODE)'; $bindings[] = $text; } } if ($user !== null && $user !== '') { - $conditions[] = 'messages.user = ?'; + $conditions[] = 'users.username = ?'; $bindings[] = $user; } if ($from !== null) { @@ -248,7 +256,8 @@ public function searchMessages( $queryBindings = [...$bindings, $perPage, $offset]; $messages = $this->db->query( - "SELECT messages.* FROM {$fromClause}{$where} ORDER BY messages.time DESC, messages.id DESC LIMIT ? OFFSET ?", + "SELECT messages.id, messages.channel_id, COALESCE(users.username, '[deleted]') AS user, messages.msg, messages.time + FROM {$fromClause}{$where} ORDER BY messages.time DESC, messages.id DESC LIMIT ? OFFSET ?", $queryBindings, )->getResultArray(); $totalPages = (int) ceil($totalItems / $perPage); @@ -282,8 +291,13 @@ public function searchMessages( */ public function insertMsg(string $name, string $message, int $current, ?int $channelId = null): int|bool { + $user = $this->db->table('users')->select('id')->where('username', $name)->get()->getRowArray(); + if (! is_array($user)) { + return false; + } + $result = $this->insert([ - 'user' => $name, + 'user_id' => (int) $user['id'], 'msg' => $message, 'time' => $current, 'channel_id' => $channelId ?? $this->generalChannelId(), @@ -318,7 +332,7 @@ public function archiveMessagesBefore(int $timestamp, ?int $channelId = null, in $archiveRows = array_map(static fn (array $row): array => [ 'id' => (int) $row['id'], 'channel_id' => (int) $row['channel_id'], - 'user' => (string) $row['user'], + 'user_id' => $row['user_id'] === null ? null : (int) $row['user_id'], 'msg' => (string) $row['msg'], 'time' => (int) $row['time'], 'archived_at' => $archivedAt, @@ -341,26 +355,29 @@ public function exportMessages(?int $channelId = null, ?string $username = null, $batchSize = max(1, min(5_000, $batchSize)); $messages = $this->db->prefixTable('messages'); $archived = $this->db->prefixTable('archived_messages'); + $users = $this->db->prefixTable('users'); $after = 0; do { - $where = ['id > ?']; + $where = ['message.id > ?']; $tableBindings = [$after]; if ($channelId !== null) { - $where[] = 'channel_id = ?'; + $where[] = 'message.channel_id = ?'; $tableBindings[] = $channelId; } if ($username !== null) { - $where[] = 'user = ?'; + $where[] = 'author.username = ?'; $tableBindings[] = $username; } $condition = implode(' AND ', $where); $bindings = [...$tableBindings, ...$tableBindings, $batchSize]; $rows = $this->db->query( "SELECT id, channel_id, user, msg, time, archived FROM ( - SELECT id, channel_id, user, msg, time, 0 AS archived FROM {$messages} WHERE {$condition} + SELECT message.id, message.channel_id, COALESCE(author.username, '[deleted]') AS user, message.msg, message.time, 0 AS archived + FROM {$messages} AS message LEFT JOIN {$users} AS author ON author.id = message.user_id WHERE {$condition} UNION ALL - SELECT id, channel_id, user, msg, time, 1 AS archived FROM {$archived} WHERE {$condition} + SELECT message.id, message.channel_id, COALESCE(author.username, '[deleted]') AS user, message.msg, message.time, 1 AS archived + FROM {$archived} AS message LEFT JOIN {$users} AS author ON author.id = message.user_id WHERE {$condition} ) AS message_export ORDER BY id ASC LIMIT ?", $bindings, )->getResultArray(); @@ -403,15 +420,20 @@ public function getMsgByUserPaginated(string $username, int $page = 1, int $perP // If not in the cache or cache expired, get from the database and store in the cache if ($result === null) { // Get total count for pagination - $totalCount = $this->where('channel_id', $channelId)->where('user', $username)->countAllResults(); + $totalCount = $this->db->table('messages AS messages') + ->join('users AS users', 'users.id = messages.user_id') + ->where('messages.channel_id', $channelId) + ->where('users.username', $username) + ->countAllResults(); // Use user index for filtering and time index for ordering - $messages = $this->where('channel_id', $channelId) - ->where('user', $username) - ->orderBy('time', 'DESC') - ->limit($perPage, $offset) - ->get() - ->getResultArray(); + $messages = $this->liveMessageBuilder() + ->where('messages.channel_id', $channelId) + ->where('users.username', $username) + ->orderBy('messages.time', 'DESC') + ->limit($perPage, $offset) + ->get() + ->getResultArray(); // Calculate total pages $totalPages = ceil($totalCount / $perPage); @@ -491,19 +513,21 @@ public function getMsgByTimeRangePaginated(int $startTime, int $endTime, int $pa // If not in the cache or cache expired, get from the database and store in the cache if ($result === null) { // Get total count for pagination - $totalCount = $this->where('channel_id', $channelId) - ->where('time >=', $startTime) - ->where('time <=', $endTime) - ->countAllResults(); + $totalCount = $this->db->table('messages') + ->where('channel_id', $channelId) + ->where('time >=', $startTime) + ->where('time <=', $endTime) + ->countAllResults(); // Use time index for filtering and ordering - $messages = $this->where('channel_id', $channelId) - ->where('time >=', $startTime) - ->where('time <=', $endTime) - ->orderBy('time', 'DESC') - ->limit($perPage, $offset) - ->get() - ->getResultArray(); + $messages = $this->liveMessageBuilder() + ->where('messages.channel_id', $channelId) + ->where('messages.time >=', $startTime) + ->where('messages.time <=', $endTime) + ->orderBy('messages.time', 'DESC') + ->limit($perPage, $offset) + ->get() + ->getResultArray(); // Calculate total pages $totalPages = ceil($totalCount / $perPage); @@ -578,6 +602,13 @@ private function toSqliteFtsQuery(string $text): string return '"' . str_replace('"', '""', $text) . '"'; } + private function liveMessageBuilder(): BaseBuilder + { + return $this->db->table('messages AS messages') + ->select("messages.id, messages.channel_id, COALESCE(users.username, '[deleted]') AS user, messages.msg, messages.time", false) + ->join('users AS users', 'users.id = messages.user_id', 'left'); + } + private function generalChannelId(): int { $row = $this->db->table('channels')->select('id')->where('slug', 'general')->get()->getRowArray(); diff --git a/bench/README.md b/bench/README.md index e692a10..e1af1b8 100644 --- a/bench/README.md +++ b/bench/README.md @@ -9,7 +9,7 @@ These k6 scenarios exercise the application through its public HTTP and WebSocke - SQLite support, or a configured MySQL database - The application migrations applied to the benchmark database -Prepare a reproducible dataset. This replaces messages owned by the reserved `k6-user-*` accounts and creates 100 local benchmark users with password `Benchmark123!`: +Prepare a reproducible dataset. This replaces messages owned by the reserved `k6_user_*` accounts and creates 100 local benchmark users with password `Benchmark123!`: ```shell DB_DRIVER=SQLite3 php spark migrate --all diff --git a/bench/lib/auth.js b/bench/lib/auth.js index e0c02e5..49f3897 100644 --- a/bench/lib/auth.js +++ b/bench/lib/auth.js @@ -12,7 +12,7 @@ export function login() { } export function loginForUser(sequence) { - const username = `k6-user-${String(sequence).padStart(3, '0')}`; + const username = `k6_user_${String(sequence).padStart(3, '0')}`; const loginPage = http.get(`${baseUrl}/auth/login`, { tags: { name: 'GET /auth/login' } }); const loginCsrf = metaValue(loginPage.body, 'csrf-token'); diff --git a/composer.json b/composer.json index ec8a5b3..6904398 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,7 @@ "test:database": "phpunit tests/Tests/Database", "test:feature": "phpunit tests/Tests/Feature", "test:integration": "phpunit --testsuite Integration --group integration --exclude-group integration-websocket", - "test:integration:mysql": "phpunit --testsuite Integration --group message-search", + "test:integration:mysql": "phpunit --testsuite Integration --group message-search --group database-constraints", "test:integration:websocket": "phpunit --testsuite Integration --group integration-websocket", "test:all": "phpunit --exclude-group integration-websocket", "test:coverage": "phpunit --colors --coverage-html build/logs/html", diff --git a/docs/database-schema.md b/docs/database-schema.md index caf5ab6..7f8de63 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -1,150 +1,114 @@ -# CodeIgniter Chat Database Schema - -This document describes the database schema used by the CodeIgniter Chat application. It includes details about each table, its columns, and the relationships between tables. - -## Tables Overview - -The CodeIgniter Chat application uses the following tables: - -1. `users` - Stores user account information -2. `messages` - Stores chat messages -3. `ci_sessions` - Stores session data (CodeIgniter's built-in session management) - -## Table Structures - -### users - -The `users` table stores information about registered users. - -| Column | Type | Constraints | Description | -|--------|------|-------------|-------------| -| id | int | PRIMARY KEY, AUTO_INCREMENT | Unique identifier for the user | -| username | varchar(255) | NOT NULL | User's username (used for login) | -| email | varchar(255) | NOT NULL | User's email address | -| password | varchar(255) | NOT NULL | Hashed password | -| created_at | datetime | NOT NULL | Timestamp when the user account was created | -| updated_at | datetime | NOT NULL | Timestamp when the user account was last updated | - -#### Indexes -- PRIMARY KEY on `id` -- UNIQUE INDEX on `username` -- UNIQUE INDEX on `email` - -### messages - -The `messages` table stores chat messages sent by users. - -| Column | Type | Constraints | Description | -|--------|------|-------------|-------------| -| id | int(7) | PRIMARY KEY, AUTO_INCREMENT | Unique identifier for the message | -| user | varchar(255) | NOT NULL | Username of the message sender | -| msg | text | NOT NULL | Content of the message | -| time | int(11) | NOT NULL, DEFAULT '0' | Unix timestamp when the message was sent | - -#### Indexes -- PRIMARY KEY on `id` - -### ci_sessions - -The `ci_sessions` table is used by CodeIgniter to store session data. - -| Column | Type | Constraints | Description | -|--------|------|-------------|-------------| -| id | varchar(40) | PRIMARY KEY | Session identifier | -| ip_address | varchar(45) | NOT NULL | IP address of the user | -| timestamp | int(10) unsigned | NOT NULL, DEFAULT '0' | Unix timestamp when the session was last updated | -| data | blob | NOT NULL | Serialized session data | - -#### Indexes -- PRIMARY KEY on `id` -- INDEX `ci_sessions_timestamp` on `timestamp` - -## Relationships - -### Logical Relationships - -While there are no explicit foreign key constraints in the database schema, there are logical relationships between the tables: - -1. **User to Messages (One-to-Many)** - - A user can send multiple messages - - Each message is associated with one user through the `user` column in the `messages` table, which corresponds to the `username` column in the `users` table - -### Data Integrity - -Since there are no explicit foreign key constraints, data integrity is maintained at the application level: - -- When a message is inserted, the application ensures that the user exists -- The application validates user input before inserting data into the database -- The application uses parameterized queries to prevent SQL injection - -## Database Diagram - -``` -+---------------+ +---------------+ -| users | | messages | -+---------------+ +---------------+ -| id (PK) | | id (PK) | -| username |<----->| user | -| email | | msg | -| password | | time | -| created_at | | | -| updated_at | | | -+---------------+ +---------------+ - -+---------------+ -| ci_sessions | -+---------------+ -| id (PK) | -| ip_address | -| timestamp | -| data | -+---------------+ +# Database schema + +The application supports MySQL 8 and SQLite. Migrations in +`app/Database/Migrations/` are the source of truth; this document highlights +the core account and message relationships and the constraints that protect +them. + +## Core relationships + +```text +users 1 ──── * messages * ──── 1 channels + │ │ + │ └─ nullable user_id preserves content after user deletion + │ + ├──── * archived_messages * ──── 1 channels + ├──── * channel_members * ──── 1 channels + ├──── * message_reactions * ──── 1 messages + └──── * push_subscriptions ``` -## SQL Scripts - -### Create Tables - -The following SQL scripts can be used to create the necessary tables: - -```sql --- Create users table -CREATE TABLE `users` ( - `id` int NOT NULL AUTO_INCREMENT, - `username` varchar(255) NOT NULL, - `email` varchar(255) NOT NULL, - `password` varchar(255) NOT NULL, - `created_at` datetime NOT NULL, - `updated_at` datetime NOT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `username` (`username`), - UNIQUE KEY `email` (`email`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - --- Create messages table -CREATE TABLE `messages` ( - `id` int(7) NOT NULL AUTO_INCREMENT, - `user` varchar(255) CHARACTER SET latin1 NOT NULL, - `msg` text CHARACTER SET latin1 NOT NULL, - `time` int(11) NOT NULL DEFAULT '0', - PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - --- Create ci_sessions table -CREATE TABLE `ci_sessions` ( - `id` varchar(40) NOT NULL, - `ip_address` varchar(45) NOT NULL, - `timestamp` int(10) unsigned NOT NULL DEFAULT '0', - `data` blob NOT NULL, - PRIMARY KEY (`id`), - KEY `ci_sessions_timestamp` (`timestamp`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; -``` - -## Notes - -1. The `users` table is managed by the `UserModel` class in `app/Models/UserModel.php`. -2. The `messages` table is managed by the `ChatModel` class in `app/Models/ChatModel.php`. -3. The `ci_sessions` table is managed by CodeIgniter's built-in session management. -4. Passwords are hashed using PHP's `password_hash()` function with the `PASSWORD_DEFAULT` algorithm. -5. The `time` column in the `messages` table stores Unix timestamps, which can be converted to human-readable dates using PHP's `date()` function. \ No newline at end of file +`messages` and `archived_messages` store the author's numeric `user_id`, not a +copy of the username. Repository reads join `users` and continue to expose a +`user` string in HTTP and WebSocket payloads. If an account is deleted, the +foreign key sets `user_id` to `NULL` and reads and exports show `[deleted]`. + +## Relevant columns + +### `users` + +| Column | Constraints | Purpose | +| --- | --- | --- | +| `id` | primary key | Stable account identifier | +| `username` | unique, `chk_users_username_format` | Login and display handle | +| `email` | unique | Login and account email | +| `password` | `chk_users_password_hash` | Output of PHP `password_hash()` | +| profile columns | see `AddUserProfileFields` | Display name, avatar, theme, notifications, and presence | + +The schema column is named `password`; it contains a password hash. The check +name uses “password hash” to make that invariant explicit. + +### `messages` + +| Column | Constraints | Purpose | +| --- | --- | --- | +| `id` | primary key | Stable message and cursor identifier | +| `channel_id` | not null, FK to `channels.id`, delete restricted | Owning channel | +| `user_id` | nullable, `fk_messages_user`, delete sets null | Author account | +| `msg` | not null, `chk_messages_msg_length` | 1–500 character body | +| `time` | not null, `chk_messages_time_positive` | Positive Unix timestamp | + +### `archived_messages` + +Archived messages preserve the original message ID, channel, author reference, +body, and timestamp and add `archived_at`. They use the equivalent named checks +`chk_archived_messages_msg_length` and +`chk_archived_messages_time_positive`, plus the nullable +`fk_archived_messages_user` foreign key. This keeps live and archived history +under the same data-integrity rules. + +## Constraint rationale + +| Constraint | Rule | Why it exists | +| --- | --- | --- | +| `chk_users_username_format` | 3–30 ASCII letters, digits, or underscores | Prevents invalid handles from direct SQL or missed validation | +| `chk_users_password_hash` | starts with `$2y$` or `$argon2` | Rejects accidental plain-text password writes | +| `chk_messages_msg_length` | character length is 1–500 | Enforces the public message contract in storage | +| `chk_messages_time_positive` | `time > 0` | Rejects zero and negative sentinel timestamps | +| `chk_archived_messages_msg_length` | character length is 1–500 | Gives archived rows parity with live rows | +| `chk_archived_messages_time_positive` | `time > 0` | Gives archived rows parity with live rows | +| `fk_messages_user` | nullable user FK, `ON DELETE SET NULL`, `ON UPDATE CASCADE` | Preserves messages without retaining a stale username | +| `fk_archived_messages_user` | nullable user FK, `ON DELETE SET NULL`, `ON UPDATE CASCADE` | Preserves archived history after account deletion | + +Application validation deliberately repeats the username, message-length, and +password requirements. It provides useful client errors; the database checks +are the final safety net for direct SQL, migrations, and missed code paths. + +## Migration behavior + +`TightenMessagesConstraints` replaces the legacy string `user` columns in both +message tables with `user_id`. It backfills IDs by matching the old username to +`users.username`. A row whose username no longer exists receives `NULL` instead +of blocking the migration. + +Run a pre-deployment audit for usernames outside the allowed format, password +values without a supported hash prefix, empty or over-500-character messages, +and non-positive timestamps. Existing rows that violate a new check cause the +migration to fail; the migration does not silently rewrite invalid account or +message data. + +On rollback, `user_id` is converted back to a username. Rows with a deleted or +otherwise missing author become `[deleted]`, so a deleted username cannot be +recovered by rolling back. + +### MySQL and MariaDB + +MySQL must be 8.0.16 or newer for enforced `CHECK` constraints; CI runs these +migrations and direct-SQL constraint tests against MySQL 8.4. MariaDB supports +checks but differs across releases in named-check syntax and metadata. Confirm +the exact MariaDB target version in staging before deployment and use the +constraint names above when inspecting or removing checks. + +### SQLite + +SQLite cannot add these checks and foreign keys to an existing table in place, +so the migrations rebuild `users`, `messages`, and `archived_messages`, copy the +data, recreate indexes and FTS5 triggers, and run `PRAGMA foreign_key_check`. +Foreign-key enforcement still requires `PRAGMA foreign_keys=ON`; CodeIgniter +enables it for the application connections. If a migration is interrupted, +inspect both the original table and any `*_constraint_rebuild` table before +retrying. + +Message full-text indexes contain only `msg` after normalization. Exact author +filtering joins `users.username`; deleted authors remain readable as +`[deleted]` but cannot match a former username. diff --git a/docs/public-api.md b/docs/public-api.md index d96255e..f631e32 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -249,15 +249,16 @@ curl -sS -b cookies.txt "$BASE_URL/api/v1/messages?page=1&per_page=10" | python - **Auth**: required - **Response**: JSON - **Query filters** (at least one is required): - - `text`: full-text search of usernames and message bodies (maximum 500 characters) - - `user`: exact username (maximum 255 characters) + - `text`: full-text search of message bodies (maximum 500 characters) + - `user`: exact username (maximum 30 characters) - `from`: inclusive, non-negative Unix timestamp - `to`: inclusive, non-negative Unix timestamp - **Pagination**: `page` defaults to 1; `per_page` defaults to 10 and is capped at 100 SQLite installations use an FTS5 index kept in sync by database triggers. -MySQL installations use a native `FULLTEXT` index. Search results are newest -first, with message ID as a deterministic tie-breaker. +MySQL installations use a native `FULLTEXT` index. Exact author filtering uses +the joined user record rather than the full-text index. Search results are +newest first, with message ID as a deterministic tie-breaker. ```json { diff --git a/tests/Tests/Integration/ChannelModelIntegrationTest.php b/tests/Tests/Integration/ChannelModelIntegrationTest.php index 36b4e07..b94e74e 100644 --- a/tests/Tests/Integration/ChannelModelIntegrationTest.php +++ b/tests/Tests/Integration/ChannelModelIntegrationTest.php @@ -18,7 +18,7 @@ public function testMigrationCreatesGeneralMembershipAndMessageDefault(): void $generalId = $channels->ensureGeneralMembership($userId); $messageId = $this->db->table('messages')->insert([ - 'user' => 'alice', + 'user_id' => $userId, 'msg' => 'A legacy client message', 'time' => 1_700_000_000, ], true); diff --git a/tests/Tests/Integration/ChatIntegrationTest.php b/tests/Tests/Integration/ChatIntegrationTest.php index 3b24fb9..2760bce 100644 --- a/tests/Tests/Integration/ChatIntegrationTest.php +++ b/tests/Tests/Integration/ChatIntegrationTest.php @@ -57,7 +57,7 @@ public function testXmlEndpointFormatsRowsReturnedByTheRealRepository(): void $this->assertIsArray($user); $this->hasInDatabase('messages', [ - 'user' => 'xmluser', + 'user_id' => $userId, 'msg' => 'Real XML row', 'time' => 1_700_000_000, ]); diff --git a/tests/Tests/Integration/DatabaseConstraintsIntegrationTest.php b/tests/Tests/Integration/DatabaseConstraintsIntegrationTest.php new file mode 100644 index 0000000..f7a35ce --- /dev/null +++ b/tests/Tests/Integration/DatabaseConstraintsIntegrationTest.php @@ -0,0 +1,175 @@ +createUser('backfill_user'); + $channelId = (new ChannelModel())->generalChannelId(); + $migration = new TightenMessagesConstraints(Database::forge($this->db)); + $migration->down(); + + $this->db->table('messages')->insert([ + 'channel_id' => $channelId, + 'user' => 'backfill_user', + 'msg' => 'Live legacy row', + 'time' => 1_700_000_000, + ]); + $this->db->table('archived_messages')->insert([ + 'id' => 900, + 'channel_id' => $channelId, + 'user' => 'backfill_user', + 'msg' => 'Archived legacy row', + 'time' => 1_600_000_000, + 'archived_at' => '2026-01-01 00:00:00', + ]); + + $migration->up(); + + $this->seeInDatabase('messages', ['user_id' => $userId, 'msg' => 'Live legacy row']); + $this->seeInDatabase('archived_messages', ['user_id' => $userId, 'msg' => 'Archived legacy row']); + $this->assertTrue($this->db->fieldExists('user_id', 'messages')); + $this->assertFalse($this->db->fieldExists('user', 'messages')); + } + + public function testMessageMigrationPreflightLeavesLegacySchemaIntact(): void + { + $migration = new TightenMessagesConstraints(Database::forge($this->db)); + $migration->down(); + $this->db->table('messages')->insert([ + 'channel_id' => (new ChannelModel())->generalChannelId(), + 'user' => '[deleted]', + 'msg' => '', + 'time' => 0, + ]); + + try { + $migration->up(); + $this->fail('Expected invalid legacy message data to stop the migration.'); + } catch (LogicException $exception) { + $this->assertStringContainsString('Cannot tighten messages', $exception->getMessage()); + } + + $this->assertTrue($this->db->fieldExists('user', 'messages')); + $this->assertFalse($this->db->fieldExists('user_id', 'messages')); + if ($this->db->getPlatform() === 'SQLite3') { + $this->assertTrue($this->db->tableExists('messages_fts')); + } + + $this->db->table('messages')->where('msg', '')->delete(); + $migration->up(); + } + + public function testDatabaseRejectsMessagesOutsideLengthLimit(): void + { + $this->expectException(DatabaseException::class); + + $this->db->table('messages')->insert([ + 'msg' => str_repeat('x', 501), + 'time' => 1_700_000_000, + ]); + } + + public function testDatabaseRejectsNonPositiveMessageTime(): void + { + $this->expectException(DatabaseException::class); + + $this->db->table('messages')->insert([ + 'msg' => 'Invalid timestamp', + 'time' => 0, + ]); + } + + public function testDatabaseRejectsArchivedMessagesOutsideLengthLimit(): void + { + $this->expectException(DatabaseException::class); + + $this->db->table('archived_messages')->insert([ + 'id' => 901, + 'channel_id' => (new ChannelModel())->generalChannelId(), + 'msg' => str_repeat('x', 501), + 'time' => 1_700_000_000, + 'archived_at' => '2026-01-01 00:00:00', + ]); + } + + public function testDatabaseRejectsNonPositiveArchivedMessageTime(): void + { + $this->expectException(DatabaseException::class); + + $this->db->table('archived_messages')->insert([ + 'id' => 902, + 'channel_id' => (new ChannelModel())->generalChannelId(), + 'msg' => 'Invalid archived timestamp', + 'time' => 0, + 'archived_at' => '2026-01-01 00:00:00', + ]); + } + + public function testDatabaseRejectsInvalidUsernameCharacters(): void + { + $this->expectException(DatabaseException::class); + + $this->db->table('users')->insert([ + 'username' => 'bad<>name', + 'email' => 'bad-name@example.com', + 'password' => password_hash('Password123!', PASSWORD_DEFAULT), + ]); + } + + public function testDatabaseRejectsPlainTextPasswordStorage(): void + { + $this->expectException(DatabaseException::class); + + $this->db->table('users')->insert([ + 'username' => 'plain_text_user', + 'email' => 'plain-text@example.com', + 'password' => 'Password123!', + ]); + } + + public function testDeletedUsersRemainReadableAcrossLiveAndArchivedHistory(): void + { + $userId = $this->createUser('departed_user'); + $channelId = (new ChannelModel())->generalChannelId(); + $messages = new ChatModel(); + $archivedId = $messages->insertMsg('departed_user', 'Archived authorship', 100, $channelId); + $liveId = $messages->insertMsg('departed_user', 'Live authorship', 200, $channelId); + $this->assertIsInt($archivedId); + $this->assertIsInt($liveId); + $this->assertSame(1, $messages->archiveMessagesBefore(150, $channelId)); + + $this->assertTrue((new UserModel())->delete($userId)); + + $this->seeInDatabase('messages', ['id' => $liveId, 'user_id' => null]); + $this->seeInDatabase('archived_messages', ['id' => $archivedId, 'user_id' => null]); + $history = $messages->getMessageHistory($channelId, limit: 10); + $this->assertSame(['[deleted]', '[deleted]'], array_column($history['messages'], 'user')); + $this->assertSame( + ['[deleted]', '[deleted]'], + array_column(iterator_to_array($messages->exportMessages(channelId: $channelId)), 'user'), + ); + } + + private function createUser(string $username): int + { + $id = (new UserModel())->createUser($username, "{$username}@example.com", 'Password123!'); + $this->assertIsInt($id); + + return $id; + } +} diff --git a/tests/Tests/Integration/MessageSearchIntegrationTest.php b/tests/Tests/Integration/MessageSearchIntegrationTest.php index 7da5ab2..0c72f25 100644 --- a/tests/Tests/Integration/MessageSearchIntegrationTest.php +++ b/tests/Tests/Integration/MessageSearchIntegrationTest.php @@ -13,6 +13,7 @@ final class MessageSearchIntegrationTest extends IntegrationTestCase { public function testSearchIndexExistsAndStaysSynchronized(): void { + $this->createUser('alice'); $model = new ChatModel(); $id = $model->insertMsg('alice', 'A cobalt telescope is ready', 1_700_000_000); $this->assertIsInt($id); @@ -32,12 +33,14 @@ public function testSearchIndexExistsAndStaysSynchronized(): void public function testSearchCombinesTextUserTimeAndPagination(): void { + $aliceId = $this->createUser('alice'); + $bobId = $this->createUser('bob'); $this->db->table('messages')->insertBatch([ - ['user' => 'alice', 'msg' => 'Indexed meteor report one', 'time' => 100], - ['user' => 'alice', 'msg' => 'Indexed meteor report two', 'time' => 200], - ['user' => 'alice', 'msg' => 'Indexed meteor report outside range', 'time' => 300], - ['user' => 'bob', 'msg' => 'Indexed meteor report by Bob', 'time' => 150], - ['user' => 'alice', 'msg' => 'Unrelated conversation', 'time' => 175], + ['user_id' => $aliceId, 'msg' => 'Indexed meteor report one', 'time' => 100], + ['user_id' => $aliceId, 'msg' => 'Indexed meteor report two', 'time' => 200], + ['user_id' => $aliceId, 'msg' => 'Indexed meteor report outside range', 'time' => 300], + ['user_id' => $bobId, 'msg' => 'Indexed meteor report by Bob', 'time' => 150], + ['user_id' => $aliceId, 'msg' => 'Unrelated conversation', 'time' => 175], ]); $result = (new ChatModel())->searchMessages('Indexed meteor report', 'alice', 100, 250, 2, 1); @@ -60,11 +63,13 @@ public function testAuthenticatedHttpSearchUsesTheRealRepository(): void $this->assertIsInt($userId); $user = $userModel->find($userId); $this->assertIsArray($user); + $aliceId = $this->createUser('alice'); + $bobId = $this->createUser('bob'); $this->db->table('messages')->insertBatch([ - ['user' => 'alice', 'msg' => 'The classroom has a brass telescope', 'time' => 200], - ['user' => 'alice', 'msg' => 'The classroom has a paper map', 'time' => 100], - ['user' => 'bob', 'msg' => 'The classroom has a brass telescope', 'time' => 200], + ['user_id' => $aliceId, 'msg' => 'The classroom has a brass telescope', 'time' => 200], + ['user_id' => $aliceId, 'msg' => 'The classroom has a paper map', 'time' => 100], + ['user_id' => $bobId, 'msg' => 'The classroom has a brass telescope', 'time' => 200], ]); $response = $this->loginAs($user) @@ -82,10 +87,14 @@ public function testAuthenticatedHttpSearchUsesTheRealRepository(): void public function testFullTextSearchUsesTheNativeIndexAtPracticalScale(): void { + $userIds = []; + for ($i = 0; $i < 20; ++$i) { + $userIds[$i] = $this->createUser('user' . $i); + } $rows = []; for ($i = 0; $i < 2_000; ++$i) { $rows[] = [ - 'user' => 'user' . ($i % 20), + 'user_id' => $userIds[$i % 20], 'msg' => $i === 1_337 ? 'distinctive quasar observatory signal' : "ordinary classroom message {$i}", 'time' => 1_700_000_000 + $i, ]; @@ -123,8 +132,8 @@ private function assertSearchIndexExists(): void $table = $this->db->prefixTable('messages'); $indexes = $this->db->query("SHOW INDEX FROM {$table} WHERE Key_name = 'idx_messages_fulltext'")->getResultArray(); - $this->assertCount(2, $indexes); - $this->assertSame(['user', 'msg'], array_values(array_unique(array_column($indexes, 'Column_name')))); + $this->assertCount(1, $indexes); + $this->assertSame(['msg'], array_values(array_unique(array_column($indexes, 'Column_name')))); } private function assertQueryPlanUsesSearchIndex(string $text): void @@ -144,10 +153,18 @@ private function assertQueryPlanUsesSearchIndex(string $text): void } $plan = $this->db->query( - "EXPLAIN SELECT messages.* FROM {$messages} AS messages WHERE MATCH(messages.user, messages.msg) AGAINST (? IN NATURAL LANGUAGE MODE)", + "EXPLAIN SELECT messages.* FROM {$messages} AS messages WHERE MATCH(messages.msg) AGAINST (? IN NATURAL LANGUAGE MODE)", [$text], )->getResultArray(); $this->assertContains('fulltext', array_map('strtolower', array_column($plan, 'type'))); $this->assertContains('idx_messages_fulltext', array_column($plan, 'key')); } + + private function createUser(string $username): int + { + $id = (new UserModel())->createUser($username, "{$username}@example.com", 'Password123!'); + $this->assertIsInt($id); + + return $id; + } } diff --git a/tests/Tests/Integration/ProfileIntegrationTest.php b/tests/Tests/Integration/ProfileIntegrationTest.php index 328b78e..69800e1 100644 --- a/tests/Tests/Integration/ProfileIntegrationTest.php +++ b/tests/Tests/Integration/ProfileIntegrationTest.php @@ -40,19 +40,19 @@ public function testProfileApiRequiresAuthentication(): void public function testAuthenticatedUserCanReadAndUpdateProfile(): void { $model = new UserModel(); - $userId = $model->createUser('profile-user', 'profile@example.com', 'Password123!'); + $userId = $model->createUser('profile_user', 'profile@example.com', 'Password123!'); $this->assertIsInt($userId); $getResult = $this->loginAs([ 'id' => $userId, - 'username' => 'profile-user', + 'username' => 'profile_user', 'email' => 'profile@example.com', ])->withHeaders(['Origin' => 'http://localhost']) ->call('get', '/api/v1/profile'); $this->assertSame(200, $getResult->response()->getStatusCode()); $profile = json_decode($getResult->getJSON(), true, flags: JSON_THROW_ON_ERROR)['profile']; - $this->assertSame('profile-user', $profile['display_name']); + $this->assertSame('profile_user', $profile['display_name']); $this->assertSame('system', $profile['theme']); $updateResult = $this->withHeaders([ @@ -76,12 +76,12 @@ public function testAuthenticatedUserCanReadAndUpdateProfile(): void public function testProfileUpdateRejectsInvalidPreferences(): void { $model = new UserModel(); - $userId = $model->createUser('profile-user', 'profile@example.com', 'Password123!'); + $userId = $model->createUser('profile_user', 'profile@example.com', 'Password123!'); $this->assertIsInt($userId); $result = $this->loginAs([ 'id' => $userId, - 'username' => 'profile-user', + 'username' => 'profile_user', 'email' => 'profile@example.com', ])->withHeaders([ 'Origin' => 'http://localhost', @@ -98,19 +98,19 @@ public function testProfileUpdateRejectsInvalidPreferences(): void public function testPublicProfileLookupDoesNotExposePrivateFields(): void { $model = new UserModel(); - $userId = $model->createUser('profile-user', 'profile@example.com', 'Password123!'); + $userId = $model->createUser('profile_user', 'profile@example.com', 'Password123!'); $this->assertIsInt($userId); $result = $this->loginAs([ 'id' => $userId, - 'username' => 'profile-user', + 'username' => 'profile_user', 'email' => 'profile@example.com', ])->withHeaders(['Origin' => 'http://localhost']) - ->call('get', '/api/v1/profiles?usernames=profile-user'); + ->call('get', '/api/v1/profiles?usernames=profile_user'); $this->assertSame(200, $result->response()->getStatusCode()); $profile = json_decode($result->getJSON(), true, flags: JSON_THROW_ON_ERROR)['profiles'][0]; - $this->assertSame('profile-user', $profile['username']); + $this->assertSame('profile_user', $profile['username']); $this->assertArrayNotHasKey('email', $profile); $this->assertArrayNotHasKey('theme', $profile); } @@ -118,7 +118,7 @@ public function testPublicProfileLookupDoesNotExposePrivateFields(): void public function testAvatarUploadIsCroppedToA256PixelWebp(): void { $model = new UserModel(); - $userId = $model->createUser('avatar-user', 'avatar@example.com', 'Password123!'); + $userId = $model->createUser('avatar_user', 'avatar@example.com', 'Password123!'); $this->assertIsInt($userId); $source = $this->createPng(400, 200); $avatarPath = WRITEPATH . "uploads/avatars/{$userId}.webp"; @@ -139,7 +139,7 @@ public function testAvatarUploadIsCroppedToA256PixelWebp(): void public function testAvatarUploadRejectsImagesLargerThan1024Pixels(): void { $model = new UserModel(); - $userId = $model->createUser('avatar-user', 'avatar@example.com', 'Password123!'); + $userId = $model->createUser('avatar_user', 'avatar@example.com', 'Password123!'); $this->assertIsInt($userId); $result = $this->uploadAvatar($this->createPng(1025, 20), $userId); @@ -152,13 +152,13 @@ public function testAvatarUploadRejectsImagesLargerThan1024Pixels(): void public function testThemeIsReappliedAfterLogoutAndLogin(): void { $model = new UserModel(); - $userId = $model->createUser('theme-user', 'theme@example.com', 'Password123!'); + $userId = $model->createUser('theme_user', 'theme@example.com', 'Password123!'); $this->assertIsInt($userId); $model->updateProfile($userId, ['theme' => 'dark']); $login = fn () => $this->withHeaders(['Origin' => 'http://localhost']) ->call('post', '/auth/processLogin', [ - 'username' => 'theme-user', + 'username' => 'theme_user', 'password' => 'Password123!', csrf_token() => csrf_hash(), ]); @@ -188,7 +188,7 @@ private function createPng(int $width, int $height): string private function uploadAvatar(string $source, int $userId): TestResponse { - session()->set(['logged_in' => true, 'user_id' => $userId, 'username' => 'avatar-user']); + session()->set(['logged_in' => true, 'user_id' => $userId, 'username' => 'avatar_user']); $request = service('incomingrequest', config(\Config\App::class), false); $files = new FileCollection(); (new ReflectionProperty(FileCollection::class, 'files'))->setValue($files, [ diff --git a/tests/Tests/Integration/RetentionIntegrationTest.php b/tests/Tests/Integration/RetentionIntegrationTest.php index 8f8566f..e7a1a45 100644 --- a/tests/Tests/Integration/RetentionIntegrationTest.php +++ b/tests/Tests/Integration/RetentionIntegrationTest.php @@ -49,14 +49,14 @@ public function testDeletePolicyUsesBoundedBatchesAndIsIdempotent(): void public function testArchivePolicyDelegatesToMessageArchivingAndIsIdempotent(): void { $user = new UserModel(); - $userId = $user->createUser('retention-user', 'retention@example.com', 'Password123!'); + $userId = $user->createUser('retention_user', 'retention@example.com', 'Password123!'); $this->assertIsInt($userId); $channelId = (new ChannelModel())->createPublic('Retention', 'retention', null, $userId); $this->assertIsInt($channelId); $messages = new ChatModel(); - $messages->insertMsg('retention-user', 'old message', strtotime('-91 days'), $channelId); - $messages->insertMsg('retention-user', 'new message', strtotime('-89 days'), $channelId); + $messages->insertMsg('retention_user', 'old message', strtotime('-91 days'), $channelId); + $messages->insertMsg('retention_user', 'new message', strtotime('-89 days'), $channelId); $command = new RetentionApply(service('logger'), service('commands')); $params = ['policy' => 'messages', 'batch-size' => '1']; diff --git a/tests/Tests/Integration/SeedMessagesCommandTest.php b/tests/Tests/Integration/SeedMessagesCommandTest.php index 2f9e43e..5c0bcc0 100644 --- a/tests/Tests/Integration/SeedMessagesCommandTest.php +++ b/tests/Tests/Integration/SeedMessagesCommandTest.php @@ -22,7 +22,7 @@ public function testItRequiresBenchmarkMode(): void $command = new SeedMessages(service('logger'), service('commands')); $this->assertSame(1, $command->run(['1'])); - $this->assertSame(0, $this->db->table('users')->like('username', 'k6-user-', 'after')->countAllResults()); + $this->assertSame(0, $this->db->table('users')->like('username', 'k6_user_', 'after')->countAllResults()); } public function testItRejectsInvalidCounts(): void @@ -40,11 +40,11 @@ public function testItReplacesBenchmarkMessagesAndCreatesLoadUsers(): void $command = new SeedMessages(service('logger'), service('commands')); $this->assertSame(0, $command->run(['3'])); - $this->assertSame(3, $this->db->table('messages')->like('user', 'k6-user-', 'after')->countAllResults()); - $this->assertSame(100, $this->db->table('users')->like('username', 'k6-user-', 'after')->countAllResults()); + $this->assertSame(3, $this->benchmarkMessages()->countAllResults()); + $this->assertSame(100, $this->db->table('users')->like('username', 'k6_user_', 'after')->countAllResults()); $this->assertSame(0, $command->run(['2'])); - $messages = $this->db->table('messages')->like('user', 'k6-user-', 'after')->orderBy('time')->get()->getResultArray(); + $messages = $this->benchmarkMessages()->orderBy('messages.time')->get()->getResultArray(); $this->assertCount(2, $messages); $this->assertSame('Benchmark message 00000001', $messages[0]['msg']); @@ -64,4 +64,11 @@ private function setBenchmarkMode(?string $value): void $_ENV['BENCHMARK_MODE'] = $value; $_SERVER['BENCHMARK_MODE'] = $value; } + + private function benchmarkMessages(): \CodeIgniter\Database\BaseBuilder + { + return $this->db->table('messages AS messages') + ->join('users AS users', 'users.id = messages.user_id') + ->like('users.username', 'k6_user_', 'after'); + } } diff --git a/tests/Tests/Integration/UserProfileRepositoryIntegrationTest.php b/tests/Tests/Integration/UserProfileRepositoryIntegrationTest.php index 0f64d6c..a6776e1 100644 --- a/tests/Tests/Integration/UserProfileRepositoryIntegrationTest.php +++ b/tests/Tests/Integration/UserProfileRepositoryIntegrationTest.php @@ -12,7 +12,7 @@ final class UserProfileRepositoryIntegrationTest extends IntegrationTestCase public function testNewUsersReceiveProfileDefaults(): void { $model = new UserModel(); - $userId = $model->createUser('profile-user', 'profile@example.com', 'Password123!'); + $userId = $model->createUser('profile_user', 'profile@example.com', 'Password123!'); $this->assertIsInt($userId); $user = $model->findUserById($userId); @@ -29,7 +29,7 @@ public function testNewUsersReceiveProfileDefaults(): void public function testProfilesCanBeUpdatedAndReadPublicly(): void { $model = new UserModel(); - $userId = $model->createUser('profile-user', 'profile@example.com', 'Password123!'); + $userId = $model->createUser('profile_user', 'profile@example.com', 'Password123!'); $this->assertIsInt($userId); $this->assertTrue($model->updateProfile($userId, [ @@ -46,9 +46,9 @@ public function testProfilesCanBeUpdatedAndReadPublicly(): void $this->assertSame('dark', $user['theme']); $this->assertSame('away', $user['presence']); - $profiles = $model->findPublicProfilesByUsernames([' profile-user ', '', 'profile-user']); + $profiles = $model->findPublicProfilesByUsernames([' profile_user ', '', 'profile_user']); $this->assertCount(1, $profiles); - $this->assertSame('profile-user', $profiles[0]['username']); + $this->assertSame('profile_user', $profiles[0]['username']); $this->assertArrayNotHasKey('email', $profiles[0]); $this->assertArrayNotHasKey('password', $profiles[0]); } diff --git a/tests/Tests/Integration/WebSocketFlowIntegrationTest.php b/tests/Tests/Integration/WebSocketFlowIntegrationTest.php index bb7653d..6c2a4bd 100644 --- a/tests/Tests/Integration/WebSocketFlowIntegrationTest.php +++ b/tests/Tests/Integration/WebSocketFlowIntegrationTest.php @@ -103,8 +103,10 @@ public function testAuthenticatedConnectionPersistsAndBroadcastsAMessage(): void public function testGetMessagesSearchReturnsADistinguishableResult(): void { + $userId = (new UserModel())->createUser('socketuser', 'socket-search@example.com', 'Password123!'); + $this->assertIsInt($userId); $this->hasInDatabase('messages', [ - 'user' => 'socketuser', + 'user_id' => $userId, 'msg' => 'A uniquely searchable websocket message', 'time' => 1_700_000_000, ]); diff --git a/tests/Tests/Unit/ChatModelTest.php b/tests/Tests/Unit/ChatModelTest.php index 1733eff..0c76ac8 100644 --- a/tests/Tests/Unit/ChatModelTest.php +++ b/tests/Tests/Unit/ChatModelTest.php @@ -47,7 +47,8 @@ public function testModelHasCorrectAllowedFields(): void { $allowedFields = $this->chatModel->allowedFields; - $this->assertContains('user', $allowedFields); + $this->assertContains('user_id', $allowedFields); + $this->assertContains('channel_id', $allowedFields); $this->assertContains('msg', $allowedFields); $this->assertContains('time', $allowedFields); } diff --git a/tests/_support/IntegrationTestCase.php b/tests/_support/IntegrationTestCase.php index f91b8ed..ace37bb 100644 --- a/tests/_support/IntegrationTestCase.php +++ b/tests/_support/IntegrationTestCase.php @@ -60,10 +60,13 @@ protected function loginAs(array $session): static protected function assertMessageInDatabase(string $username, string $message): void { - $this->seeInDatabase('messages', [ - 'user' => $username, - 'msg' => $message, - ]); + $count = $this->db->table('messages AS messages') + ->join('users AS users', 'users.id = messages.user_id') + ->where('users.username', $username) + ->where('messages.msg', $message) + ->countAllResults(); + + $this->assertSame(1, $count); } private function resetApplicationServices(): void