mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
94dbaf073b
Introduced a Vitest-based backend unit testing framework with supporting scripts, helpers, and GitHub Actions integration. Refactored the admin settings page to a modular architecture, splitting monolithic logic into feature-specific tabs and hooks for improved maintainability and testability. Updated documentation to reflect the new testing setup and settings architecture, and added new dependencies for testing utilities.
304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
/**
|
|
* Component: Local Auth Provider Tests
|
|
* Documentation: documentation/backend/services/auth.md
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createPrismaMock } from '../../helpers/prisma';
|
|
|
|
const prismaMock = createPrismaMock();
|
|
const configMock = vi.hoisted(() => ({ get: vi.fn() }));
|
|
const encryptionMock = vi.hoisted(() => ({
|
|
encrypt: vi.fn((value: string) => `enc:${value}`),
|
|
decrypt: vi.fn((value: string) => value.replace('enc:', '')),
|
|
}));
|
|
|
|
const bcryptCompare = vi.fn();
|
|
const bcryptHash = vi.fn();
|
|
|
|
vi.mock('@/lib/db', () => ({
|
|
prisma: prismaMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/config.service', () => ({
|
|
getConfigService: () => configMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/encryption.service', () => ({
|
|
getEncryptionService: () => encryptionMock,
|
|
}));
|
|
|
|
vi.mock('bcrypt', () => ({
|
|
default: { compare: bcryptCompare, hash: bcryptHash },
|
|
compare: bcryptCompare,
|
|
hash: bcryptHash,
|
|
}));
|
|
|
|
describe('LocalAuthProvider', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('logs in approved local users with valid password', async () => {
|
|
prismaMock.user.findFirst.mockResolvedValue({
|
|
id: 'user-1',
|
|
plexId: 'local-user',
|
|
plexUsername: 'user',
|
|
role: 'user',
|
|
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();
|
|
const result = await provider.handleCallback({ username: 'user', password: 'pass' });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.user?.authProvider).toBe('local');
|
|
expect(result.tokens?.accessToken).toBeTruthy();
|
|
expect(result.tokens?.refreshToken).toBeTruthy();
|
|
});
|
|
|
|
it('rejects login when credentials are missing', async () => {
|
|
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
|
const provider = new LocalAuthProvider();
|
|
const result = await provider.handleCallback({ username: '', password: '' });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Username and password required');
|
|
});
|
|
|
|
it('blocks login when approval is pending', async () => {
|
|
prismaMock.user.findFirst.mockResolvedValue({
|
|
id: 'user-2',
|
|
plexId: 'local-user',
|
|
plexUsername: 'user',
|
|
role: 'user',
|
|
authProvider: 'local',
|
|
authToken: 'enc:hash',
|
|
registrationStatus: 'pending_approval',
|
|
deletedAt: null,
|
|
});
|
|
|
|
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
|
const provider = new LocalAuthProvider();
|
|
const result = await provider.handleCallback({ username: 'user', password: 'pass' });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.requiresApproval).toBe(true);
|
|
});
|
|
|
|
it('rejects login when account is rejected', async () => {
|
|
prismaMock.user.findFirst.mockResolvedValue({
|
|
id: 'user-2b',
|
|
plexId: 'local-user',
|
|
plexUsername: 'user',
|
|
role: 'user',
|
|
authProvider: 'local',
|
|
authToken: 'enc:hash',
|
|
registrationStatus: 'rejected',
|
|
deletedAt: null,
|
|
});
|
|
|
|
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
|
const provider = new LocalAuthProvider();
|
|
const result = await provider.handleCallback({ username: 'user', password: 'pass' });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('rejected');
|
|
});
|
|
|
|
it('rejects login with invalid password', async () => {
|
|
prismaMock.user.findFirst.mockResolvedValue({
|
|
id: 'user-3',
|
|
plexId: 'local-user',
|
|
plexUsername: 'user',
|
|
role: 'user',
|
|
authProvider: 'local',
|
|
authToken: 'enc:hash',
|
|
registrationStatus: 'approved',
|
|
deletedAt: null,
|
|
});
|
|
bcryptCompare.mockResolvedValue(false);
|
|
|
|
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
|
const provider = new LocalAuthProvider();
|
|
const result = await provider.handleCallback({ username: 'user', password: 'bad' });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toMatch(/invalid username or password/i);
|
|
});
|
|
|
|
it('rejects login when password hash cannot be decrypted', async () => {
|
|
prismaMock.user.findFirst.mockResolvedValue({
|
|
id: 'user-4',
|
|
plexId: 'local-user',
|
|
plexUsername: 'user',
|
|
role: 'user',
|
|
authProvider: 'local',
|
|
authToken: 'enc:hash',
|
|
registrationStatus: 'approved',
|
|
deletedAt: null,
|
|
});
|
|
encryptionMock.decrypt.mockImplementationOnce(() => {
|
|
throw new Error('decrypt failed');
|
|
});
|
|
|
|
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
|
const provider = new LocalAuthProvider();
|
|
const result = await provider.handleCallback({ username: 'user', password: 'pass' });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toMatch(/invalid username or password/i);
|
|
});
|
|
|
|
it('rejects login when user is not found', async () => {
|
|
prismaMock.user.findFirst.mockResolvedValue(null);
|
|
|
|
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
|
const provider = new LocalAuthProvider();
|
|
const result = await provider.handleCallback({ username: 'user', password: 'pass' });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toMatch(/invalid username or password/i);
|
|
});
|
|
|
|
it('blocks registration when disabled', async () => {
|
|
configMock.get.mockResolvedValueOnce('false');
|
|
|
|
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).toMatch(/registration is disabled/i);
|
|
});
|
|
|
|
it('rejects short usernames or passwords on registration', async () => {
|
|
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
|
const provider = new LocalAuthProvider();
|
|
|
|
let result = await provider.register({ username: 'ab', password: 'password123' });
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Username');
|
|
|
|
result = await provider.register({ username: 'user', password: 'short' });
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Password');
|
|
});
|
|
|
|
it('rejects registration when username is taken', async () => {
|
|
configMock.get.mockResolvedValueOnce('true');
|
|
prismaMock.user.findFirst.mockResolvedValue({ id: 'user-10' });
|
|
|
|
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');
|
|
});
|
|
|
|
it('creates admin user on first registration', async () => {
|
|
configMock.get.mockResolvedValueOnce('true'); // registration enabled
|
|
configMock.get.mockResolvedValueOnce('false'); // no admin approval
|
|
prismaMock.user.findFirst.mockResolvedValue(null);
|
|
prismaMock.user.count.mockResolvedValue(0);
|
|
prismaMock.user.create.mockResolvedValue({
|
|
id: 'user-1',
|
|
plexId: 'local-user',
|
|
plexUsername: 'user',
|
|
role: 'admin',
|
|
});
|
|
bcryptHash.mockResolvedValue('hash');
|
|
|
|
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(true);
|
|
expect(result.user?.role).toBe('admin');
|
|
});
|
|
|
|
it('returns pending approval when admin approval is required', async () => {
|
|
configMock.get.mockResolvedValueOnce('true'); // registration enabled
|
|
configMock.get.mockResolvedValueOnce('true'); // admin approval required
|
|
prismaMock.user.findFirst.mockResolvedValue(null);
|
|
prismaMock.user.count.mockResolvedValue(2);
|
|
prismaMock.user.create.mockResolvedValue({
|
|
id: 'user-11',
|
|
plexId: 'local-user',
|
|
plexUsername: 'user',
|
|
role: 'user',
|
|
});
|
|
bcryptHash.mockResolvedValue('hash');
|
|
|
|
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.requiresApproval).toBe(true);
|
|
});
|
|
|
|
it('returns false for non-local or missing users during access validation', async () => {
|
|
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
|
|
|
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
|
const provider = new LocalAuthProvider();
|
|
const missing = await provider.validateAccess({ id: 'user-12', username: 'x' });
|
|
|
|
expect(missing).toBe(false);
|
|
|
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
|
id: 'user-13',
|
|
authProvider: 'plex',
|
|
deletedAt: null,
|
|
registrationStatus: 'approved',
|
|
});
|
|
|
|
const notLocal = await provider.validateAccess({ id: 'user-13', username: 'x' });
|
|
expect(notLocal).toBe(false);
|
|
});
|
|
|
|
it('returns null for refresh token placeholder', async () => {
|
|
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
|
const provider = new LocalAuthProvider();
|
|
|
|
const tokens = await provider.refreshToken('refresh');
|
|
expect(tokens).toBeNull();
|
|
});
|
|
|
|
it('rejects access for deleted or unapproved users', async () => {
|
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
|
id: 'user-4',
|
|
authProvider: 'local',
|
|
deletedAt: new Date(),
|
|
registrationStatus: 'approved',
|
|
});
|
|
|
|
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
|
const provider = new LocalAuthProvider();
|
|
const deletedAccess = await provider.validateAccess({ id: 'user-4', username: 'x' });
|
|
|
|
expect(deletedAccess).toBe(false);
|
|
|
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
|
id: 'user-5',
|
|
authProvider: 'local',
|
|
deletedAt: null,
|
|
registrationStatus: 'pending_approval',
|
|
});
|
|
|
|
const pendingAccess = await provider.validateAccess({ id: 'user-5', username: 'x' });
|
|
expect(pendingAccess).toBe(false);
|
|
});
|
|
});
|
|
|
|
|
|
|