Add backend unit test framework and modularize settings UI

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.
This commit is contained in:
kikootwo
2026-01-15 16:49:59 -05:00
parent b3f89d67bb
commit 94dbaf073b
127 changed files with 23549 additions and 2868 deletions
@@ -0,0 +1,303 @@
/**
* 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);
});
});