Normalize local usernames to lowercase

Normalize local account usernames by trimming and lowercasing across the stack. Added a Prisma migration to lowercase existing plex_username and rewrite local plex_id values for non-deleted accounts. Updated LocalAuthProvider, admin login route, and setup completion to use normalized usernames when looking up, creating, and storing users (including plexId `local-{username}`). Added/updated tests to assert case-insensitive lookups, storage of lowercased usernames/plexIds, and duplicate username rejection.
This commit is contained in:
kikootwo
2026-03-04 12:47:09 -05:00
parent d0ce485bdc
commit 441724c378
5 changed files with 87 additions and 8 deletions
@@ -167,6 +167,31 @@ describe('LocalAuthProvider', () => {
expect(result.error).toMatch(/invalid username or password/i);
});
it('normalizes username to lowercase on login', async () => {
prismaMock.user.findFirst.mockResolvedValue({
id: 'user-ci',
plexId: 'local-admin',
plexUsername: 'admin',
role: 'admin',
authProvider: 'local',
authToken: 'enc:hash',
registrationStatus: 'approved',
deletedAt: null,
});
prismaMock.user.update.mockResolvedValue({});
bcryptCompare.mockResolvedValue(true);
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
const provider = new LocalAuthProvider();
await provider.handleCallback({ username: 'Admin', password: 'pass' });
expect(prismaMock.user.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ plexUsername: 'admin' }),
})
);
});
it('blocks registration when disabled', async () => {
configMock.get.mockResolvedValueOnce('false');
@@ -237,6 +262,51 @@ describe('LocalAuthProvider', () => {
expect(result.error).toContain('Username already taken');
});
it('stores lowercase username and plexId on registration', async () => {
configMock.get.mockResolvedValueOnce('true'); // registration enabled
configMock.get.mockResolvedValueOnce('false'); // no admin approval
prismaMock.user.findFirst.mockResolvedValue(null);
prismaMock.user.count.mockResolvedValue(1);
prismaMock.user.create.mockResolvedValue({
id: 'user-ci2',
plexId: 'local-myuser',
plexUsername: 'myuser',
role: 'user',
});
bcryptHash.mockResolvedValue('hash');
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
const provider = new LocalAuthProvider();
await provider.register({ username: 'MyUser', password: 'password123' });
expect(prismaMock.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
plexId: 'local-myuser',
plexUsername: 'myuser',
}),
})
);
});
it('rejects duplicate username case-insensitively on registration', async () => {
configMock.get.mockResolvedValueOnce('true'); // registration enabled
prismaMock.user.findFirst.mockResolvedValue({ id: 'user-existing' });
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
const provider = new LocalAuthProvider();
const result = await provider.register({ username: 'User', password: 'password123' });
expect(result.success).toBe(false);
expect(result.error).toContain('Username already taken');
// The lookup should use the lowercased username
expect(prismaMock.user.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ plexUsername: 'user' }),
})
);
});
it('creates admin user on first registration', async () => {
configMock.get.mockResolvedValueOnce('true'); // registration enabled
configMock.get.mockResolvedValueOnce('false'); // no admin approval