fix: make handle lookups case-insensitive and pin the reset flag

Review follow-ups on the team columns.

NOCASE moves onto the `handle` column itself, not just the index. Index-only
NOCASE makes uniqueness case-insensitive while leaving `WHERE handle = ?`
case-sensitive, so the same lookup would match on mysql (utf8mb4_unicode_ci)
and miss on sqlite. Postgres still needs lower(handle) at the call site.

`requires_password_change` becomes NOT NULL DEFAULT 0 on all three dialects,
matching the three sibling `requires_*` flags. Left nullable, any query written
as `= 0` would silently exclude every pre-existing user.
This commit is contained in:
Juan Castro
2026-09-03 12:10:48 -04:00
parent 2a5c089dbe
commit eae25459f4
4 changed files with 23 additions and 11 deletions
@@ -134,8 +134,7 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)',
['design-team'],
);
// Without COLLATE NOCASE this differs per engine: MySQL rejects it via
// utf8mb4_unicode_ci while sqlite and postgres would let it through.
// Without NOCASE this differs per engine: mysql rejects, the others accept.
await expect(
client.write(
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MAX(`id`) FROM `group`)',
@@ -144,6 +143,20 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
).rejects.toThrow(/UNIQUE/iu);
});
it('matches a handle case-insensitively on lookup, not just on insert', async () => {
// Index-only NOCASE would leave `WHERE handle = ?` case-sensitive here while
// mysql's utf8mb4_unicode_ci column matched -- one query, two behaviours.
await client.write(
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)',
['design-team'],
);
await expect(
client.read('SELECT `handle` FROM `group` WHERE `handle` = ?', [
'Design-Team',
]),
).resolves.toEqual([{ handle: 'design-team' }]);
});
it('runs the javascript migrations, not just the .sql ones', async () => {
// The `system` user only exists because 0025 (a .dbmig.js file) ran
// inside the migration VM.
@@ -24,7 +24,7 @@ CALL _puter_add_col('group', 'name', '`name` varchar(255) DEFAULT NULL');
CALL _puter_add_col('group', 'handle', '`handle` varchar(64) DEFAULT NULL');
CALL _puter_add_col('group', 'deleted_at', '`deleted_at` timestamp NULL DEFAULT NULL');
CALL _puter_add_col('jct_user_group', 'org_owned', '`org_owned` tinyint(1) DEFAULT NULL');
CALL _puter_add_col('user', 'requires_password_change', '`requires_password_change` tinyint(1) DEFAULT NULL');
CALL _puter_add_col('user', 'requires_password_change', '`requires_password_change` tinyint(1) NOT NULL DEFAULT ''0''');
DROP PROCEDURE IF EXISTS _puter_add_team_indexes;
DELIMITER //
@@ -35,4 +35,4 @@ ALTER TABLE jct_user_group ADD COLUMN IF NOT EXISTS org_owned smallint;
CREATE INDEX IF NOT EXISTS idx_jct_user_group_group
ON jct_user_group (group_id, user_id);
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_password_change smallint;
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_password_change smallint NOT NULL DEFAULT 0;
@@ -15,16 +15,15 @@
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Teams: a `group` row with `kind = 'team'` owns accounts and pays for them.
-- Seeded system groups keep `kind` NULL, so a team query never returns them.
-- A `group` row with `kind = 'team'` owns accounts and pays for them; seeded
-- system groups keep `kind` NULL so a team query never returns them.
ALTER TABLE `group` ADD COLUMN `kind` TEXT DEFAULT NULL;
ALTER TABLE `group` ADD COLUMN `name` TEXT DEFAULT NULL;
ALTER TABLE `group` ADD COLUMN `handle` TEXT DEFAULT NULL;
ALTER TABLE `group` ADD COLUMN `handle` TEXT COLLATE NOCASE DEFAULT NULL;
ALTER TABLE `group` ADD COLUMN `deleted_at` TIMESTAMP DEFAULT NULL;
-- NOCASE so a handle is unique the way a username is (see 0055). MySQL gets this
-- from utf8mb4_unicode_ci; postgres needs a lower() index. NULLs stay distinct.
-- NOCASE on both column and index, so lookups match mysql's utf8mb4_unicode_ci.
CREATE UNIQUE INDEX IF NOT EXISTS `idx_group_handle`
ON `group` (`handle` COLLATE NOCASE);
@@ -38,5 +37,5 @@ ALTER TABLE `jct_user_group` ADD COLUMN `org_owned` INTEGER DEFAULT NULL;
CREATE INDEX IF NOT EXISTS `idx_jct_user_group_group`
ON `jct_user_group` (`group_id`, `user_id`);
-- Fourth `requires_*` flag, enforced by assertVerifiedAccount. Set on admin reset.
ALTER TABLE `user` ADD COLUMN `requires_password_change` INTEGER DEFAULT NULL;
-- Fourth `requires_*` flag; assertVerifiedAccount will enforce it in phase 5.
ALTER TABLE `user` ADD COLUMN `requires_password_change` INTEGER NOT NULL DEFAULT 0;