fix(auth): match email addresses case-insensitively everywhere

Login and the OIDC fallback match emails case-insensitively, but the
password reset lookup and the admin create and edit checks compared
exactly. An account stored as Foo@x.com never got a reset mail, and an
admin could create bob@x next to Bob@x, after which login picked
whichever row came first. The signup collision check and the first-user
count also move inside the signup transaction.
This commit is contained in:
Maurice
2026-10-07 16:07:23 +02:00
parent e7ab0fc21e
commit 63f7eab4bb
5 changed files with 53 additions and 45 deletions
+12 -1
View File
@@ -975,7 +975,7 @@ describe('createUser — the pre-existing uniqueness-check TOCTOU window (AD2-4,
// INSERT — the two statements are two separate, un-transacted repository
// calls, exactly as the legacy raw SQL was.
const usersRepo = await createTestUsersRepo(testDb);
const spy = vi.spyOn(usersRepo, 'findIdByEmailExact').mockResolvedValueOnce(null);
const spy = vi.spyOn(usersRepo, 'findIdByEmailCI').mockResolvedValueOnce(null);
// The pre-check said "clear," but `users.email` carries its own UNIQUE
// index (`Migration20200101000000_baseline_schema.ts`) — that's what
@@ -990,4 +990,15 @@ describe('createUser — the pre-existing uniqueness-check TOCTOU window (AD2-4,
spy.mockRestore();
});
it('ADMIN-SVC-095 — an address that differs only in case is already taken, on create and on update', async () => {
const { user: existing } = createUser(testDb, { email: 'Case@Test.example.com' });
const { user: other } = createUser(testDb, { email: 'other-case@test.example.com' });
await expect(svcCreateUser({ username: 'caseuser', email: 'case@test.example.com', password: 'ValidPass1!' }))
.resolves.toEqual({ error: 'Email already taken', status: 409 });
await expect(updateUser(String(other.id), { email: 'CASE@TEST.EXAMPLE.COM' }))
.resolves.toEqual({ error: 'Email already taken', status: 409 });
expect(existing.id).toBeGreaterThan(0);
});
});