mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
bb5cc8f795
Login, password reset and the sign-up checks all match emails case-insensitively, but the only constraint was the column's own UNIQUE, which SQLite compares case-sensitively. Two accounts whose addresses differ only in case (legacy rows, two sign-ups at once) left a login to resolve to whichever row came first, and LOWER(email) lookups had no index to use. A migration adds a unique index on lower(email). An install that already holds such a pair must still start, so the migration then leaves the index out, logs how many addresses are shared and by which user ids (not the addresses), and records email_case_index_pending. Since a migration runs once, db/email-case-index.ts runs on every boot while that flag is set: it repeats the warning until the accounts are told apart, then creates the index and clears the flag. The app-level checks keep guarding meanwhile. A write that passes the up-front check and still loses the race is now refused by the database. The users repository turns that unique violation into UserIdentityTakenError, and sign-up, admin create and edit, and the profile update answer it with the same 409 their checks give, instead of a 500 or an unhandled rejection (ADMIN-SVC-094 pinned the old behaviour and now pins the 409). The entity generator could not see an expression index (PRAGMA index_info names its column NULL), so PARITY-009c found a db index with no entity declaration. Rule 14 carries such an index into the entity as a named entry with its own statement; Users.entity.ts is regenerated.
100 lines
4.2 KiB
TypeScript
100 lines
4.2 KiB
TypeScript
/**
|
|
* `Migration20200101042400_one_account_per_email_whatever_its_case` and the
|
|
* boot step that finishes its job (`src/db/email-case-index.ts`): one account
|
|
* per email address whatever its case, without ever failing the boot of an
|
|
* install that already holds such a pair.
|
|
*/
|
|
import { ensureEmailCaseIndex } from '../../../src/db/email-case-index';
|
|
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
|
|
import type { MikroORM } from '@mikro-orm/sqlite';
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const TARGET = 'Migration20200101042400_one_account_per_email_whatever_its_case';
|
|
|
|
let orm: MikroORM | null = null;
|
|
|
|
async function ormBeforeTarget(): Promise<MikroORM> {
|
|
orm = await createMigrationOrm();
|
|
const names = await pendingNames(orm);
|
|
const idx = names.indexOf(TARGET);
|
|
expect(idx).toBeGreaterThan(0);
|
|
await migrateTo(orm, names[idx - 1]);
|
|
return orm;
|
|
}
|
|
|
|
async function addUser(db: MikroORM, id: number, email: string): Promise<void> {
|
|
await rawExec(db, 'INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?)', [id, `user${id}`, email, 'x']);
|
|
}
|
|
|
|
const hasIndex = async (db: MikroORM) =>
|
|
(await rawQuery(db, `SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_users_email_lower'`)).length === 1;
|
|
const pendingFlag = async (db: MikroORM) =>
|
|
rawQuery<{ value: string }>(db, `SELECT value FROM app_settings WHERE key = 'email_case_index_pending'`);
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
await orm?.close(true);
|
|
orm = null;
|
|
});
|
|
|
|
describe('one account per email address, whatever its case', () => {
|
|
it('EMAILCI-001: without case duplicates the index is created and refuses a second account in other case', async () => {
|
|
const db = await ormBeforeTarget();
|
|
await addUser(db, 1, 'Anna@Example.test');
|
|
await addUser(db, 2, 'bob@example.test');
|
|
|
|
await migrateTo(db, TARGET);
|
|
|
|
expect(await hasIndex(db)).toBe(true);
|
|
expect(await pendingFlag(db)).toEqual([]);
|
|
await expect(addUser(db, 3, 'anna@example.TEST')).rejects.toThrow(/UNIQUE constraint failed/);
|
|
// Mixed case itself is fine: only a second account for the same address is not.
|
|
expect(await rawQuery(db, 'SELECT email FROM users ORDER BY id')).toEqual([
|
|
{ email: 'Anna@Example.test' },
|
|
{ email: 'bob@example.test' },
|
|
]);
|
|
}, 30000);
|
|
|
|
it('EMAILCI-002: with case duplicates the boot goes on, names the accounts, and the index waits', async () => {
|
|
const db = await ormBeforeTarget();
|
|
await addUser(db, 1, 'anna@example.test');
|
|
await addUser(db, 2, 'Anna@Example.test');
|
|
await addUser(db, 3, 'carl@example.test');
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
await expect(migrateTo(db, TARGET)).resolves.toBeUndefined();
|
|
|
|
expect(await hasIndex(db)).toBe(false);
|
|
expect(await pendingFlag(db)).toEqual([{ value: 'true' }]);
|
|
const message = String(warn.mock.calls[0]?.[0]);
|
|
expect(message).toContain('1 email address(es) belong to more than one account');
|
|
expect(message).toContain('user ids 1, 2');
|
|
expect(message).not.toContain('anna@example.test');
|
|
}, 30000);
|
|
|
|
it('EMAILCI-003: the boot step repeats the warning while the pair remains, then creates the index once it is gone', async () => {
|
|
const db = await ormBeforeTarget();
|
|
await addUser(db, 1, 'anna@example.test');
|
|
await addUser(db, 2, 'ANNA@example.test');
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
await migrateTo(db, TARGET);
|
|
warn.mockClear();
|
|
|
|
await ensureEmailCaseIndex(db.em.getConnection());
|
|
expect(await hasIndex(db)).toBe(false);
|
|
expect(String(warn.mock.calls[0]?.[0])).toContain('still belong to more than one account');
|
|
|
|
await rawExec(db, `UPDATE users SET email = 'anna.work@example.test' WHERE id = 2`);
|
|
await ensureEmailCaseIndex(db.em.getConnection());
|
|
expect(await hasIndex(db)).toBe(true);
|
|
expect(await pendingFlag(db)).toEqual([]);
|
|
|
|
// Nothing owed any more: a later boot does nothing.
|
|
warn.mockClear();
|
|
await ensureEmailCaseIndex(db.em.getConnection());
|
|
expect(warn).not.toHaveBeenCalled();
|
|
}, 30000);
|
|
});
|