Files
ReadMeABook/tests/services/auth/oidc-auth-provider.test.ts
T
kikootwo 94dbaf073b 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.
2026-01-28 11:41:59 -05:00

376 lines
13 KiB
TypeScript

/**
* Component: OIDC 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) => value),
decrypt: vi.fn((value: string) => value),
}));
const clientMock = {
authorizationUrl: vi.fn(),
callback: vi.fn(),
userinfo: vi.fn(),
};
const issuerMock = {
Client: class {
constructor() {
return clientMock;
}
},
};
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configMock,
}));
vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => encryptionMock,
}));
const schedulerMock = vi.hoisted(() => ({
triggerJobNow: vi.fn(),
}));
vi.mock('@/lib/services/scheduler.service', () => ({
getSchedulerService: () => schedulerMock,
}));
vi.mock('@/lib/utils/jwt', () => ({
generateAccessToken: vi.fn(() => 'access-token'),
generateRefreshToken: vi.fn(() => 'refresh-token'),
}));
vi.mock('@/lib/utils/url', () => ({
getBaseUrl: () => 'http://localhost:3030',
}));
vi.mock('openid-client', () => ({
Issuer: {
discover: vi.fn(async () => issuerMock),
},
generators: {
state: vi.fn(() => 'state-1'),
nonce: vi.fn(() => 'nonce-1'),
codeVerifier: vi.fn(() => 'verifier-1'),
codeChallenge: vi.fn(() => 'challenge-1'),
},
}));
describe('OIDCAuthProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.PUBLIC_URL = 'http://localhost:3030';
});
const setConfig = (values: Record<string, string | null>) => {
configMock.get.mockImplementation(async (key: string) => values[key] ?? null);
};
it('returns error when code or state is missing', async () => {
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
const provider = new OIDCAuthProvider();
const result = await provider.handleCallback({});
expect(result.success).toBe(false);
expect(result.error).toMatch(/missing authorization code or state/i);
});
it('returns error when provider sends an error', async () => {
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
const provider = new OIDCAuthProvider();
const result = await provider.handleCallback({ error: 'access_denied' });
expect(result.success).toBe(false);
expect(result.error).toContain('access_denied');
});
it('returns error for invalid callback state', async () => {
setConfig({
'oidc.issuer_url': 'https://issuer',
'oidc.client_id': 'client',
'oidc.client_secret': 'secret',
});
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
const provider = new OIDCAuthProvider();
const result = await provider.handleCallback({ code: 'code', state: 'missing' });
expect(result.success).toBe(false);
expect(result.error).toMatch(/invalid or expired state/i);
});
it('initiates login and returns redirect URL with state', async () => {
setConfig({
'oidc.issuer_url': 'https://issuer',
'oidc.client_id': 'client',
'oidc.client_secret': 'secret',
});
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
const provider = new OIDCAuthProvider();
const result = await provider.initiateLogin();
expect(result.redirectUrl).toBe('https://issuer/auth');
expect(result.state).toBe('state-1');
});
it('throws when OIDC is not fully configured', async () => {
setConfig({
'oidc.issuer_url': null,
'oidc.client_id': null,
'oidc.client_secret': null,
});
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
const provider = new OIDCAuthProvider();
await expect(provider.initiateLogin()).rejects.toThrow('Failed to initiate OIDC authentication');
});
it('blocks access when group claim is missing', async () => {
setConfig({
'oidc.issuer_url': 'https://issuer',
'oidc.client_id': 'client',
'oidc.client_secret': 'secret',
'oidc.access_control_method': 'group_claim',
'oidc.access_group_claim': 'groups',
'oidc.access_group_value': 'readmeabook-users',
});
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
clientMock.callback.mockResolvedValue({ access_token: 'token' });
clientMock.userinfo.mockResolvedValue({ sub: 'sub-1', groups: ['other-group'] });
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
const provider = new OIDCAuthProvider();
await provider.initiateLogin();
const result = await provider.handleCallback({ code: 'code', state: 'state-1' });
expect(result.success).toBe(false);
expect(result.error).toMatch(/do not have access/i);
});
it('allows access for allowed list emails and returns tokens', async () => {
setConfig({
'oidc.issuer_url': 'https://issuer',
'oidc.client_id': 'client',
'oidc.client_secret': 'secret',
'oidc.access_control_method': 'allowed_list',
'oidc.allowed_emails': JSON.stringify(['user@example.com']),
'oidc.allowed_usernames': JSON.stringify([]),
});
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
clientMock.callback.mockResolvedValue({ access_token: 'token' });
clientMock.userinfo.mockResolvedValue({ sub: 'sub-3', email: 'user@example.com' });
prismaMock.user.count.mockResolvedValue(1);
prismaMock.user.upsert.mockResolvedValue({
id: 'user-1',
plexUsername: 'user@example.com',
plexEmail: 'user@example.com',
role: 'user',
avatarUrl: null,
});
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
const provider = new OIDCAuthProvider();
await provider.initiateLogin();
const result = await provider.handleCallback({ code: 'code', state: 'state-1' });
expect(result.success).toBe(true);
expect(result.tokens?.accessToken).toBe('access-token');
});
it('returns requiresApproval for admin approval flow', async () => {
setConfig({
'oidc.issuer_url': 'https://issuer',
'oidc.client_id': 'client',
'oidc.client_secret': 'secret',
'oidc.access_control_method': 'admin_approval',
'oidc.provider_name': 'TestOIDC',
});
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
clientMock.callback.mockResolvedValue({ access_token: 'token' });
clientMock.userinfo.mockResolvedValue({ sub: 'sub-2', preferred_username: 'user' });
prismaMock.user.count.mockResolvedValue(2);
prismaMock.user.findFirst.mockResolvedValue(null);
prismaMock.user.create.mockResolvedValue({});
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
const provider = new OIDCAuthProvider();
await provider.initiateLogin();
const result = await provider.handleCallback({ code: 'code', state: 'state-1' });
expect(result.success).toBe(false);
expect(result.requiresApproval).toBe(true);
});
it('bypasses approval for the first admin user', async () => {
setConfig({
'oidc.issuer_url': 'https://issuer',
'oidc.client_id': 'client',
'oidc.client_secret': 'secret',
'oidc.access_control_method': 'admin_approval',
'oidc.provider_name': 'TestOIDC',
'oidc.admin_claim_enabled': 'true',
'oidc.admin_claim_name': 'groups',
'oidc.admin_claim_value': 'admins',
});
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
clientMock.callback.mockResolvedValue({ access_token: 'token' });
clientMock.userinfo.mockResolvedValue({ sub: 'sub-4', preferred_username: 'first', groups: ['admins'] });
prismaMock.user.count.mockResolvedValue(0);
prismaMock.user.findFirst.mockResolvedValue(null);
prismaMock.user.upsert.mockResolvedValue({
id: 'user-2',
plexUsername: 'first',
plexEmail: null,
role: 'admin',
avatarUrl: null,
});
prismaMock.scheduledJob.findFirst.mockResolvedValue({ id: 'sched-1' });
prismaMock.configuration.upsert.mockResolvedValue({});
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
const provider = new OIDCAuthProvider();
await provider.initiateLogin();
const result = await provider.handleCallback({ code: 'code', state: 'state-1' });
expect(result.success).toBe(true);
expect(result.user?.isAdmin).toBe(true);
expect(schedulerMock.triggerJobNow).toHaveBeenCalled();
});
it('blocks pending and rejected users during admin approval', async () => {
setConfig({
'oidc.issuer_url': 'https://issuer',
'oidc.client_id': 'client',
'oidc.client_secret': 'secret',
'oidc.access_control_method': 'admin_approval',
});
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
clientMock.callback.mockResolvedValue({ access_token: 'token' });
clientMock.userinfo.mockResolvedValue({ sub: 'sub-5', preferred_username: 'pending' });
prismaMock.user.count.mockResolvedValue(2);
prismaMock.user.findFirst.mockResolvedValue({ registrationStatus: 'pending_approval' });
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
const provider = new OIDCAuthProvider();
await provider.initiateLogin();
const pending = await provider.handleCallback({ code: 'code', state: 'state-1' });
expect(pending.success).toBe(false);
expect(pending.requiresApproval).toBe(true);
prismaMock.user.findFirst.mockResolvedValue({ registrationStatus: 'rejected' });
await provider.initiateLogin();
const rejected = await provider.handleCallback({ code: 'code', state: 'state-1' });
expect(rejected.success).toBe(false);
expect(rejected.error).toContain('rejected');
});
it('returns false when access validation fails', async () => {
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-3',
authProvider: 'oidc',
registrationStatus: 'pending_approval',
});
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
const provider = new OIDCAuthProvider();
const result = await provider.validateAccess({ id: 'user-3', username: 'user', isAdmin: false, authProvider: 'oidc' });
expect(result).toBe(false);
});
it('returns true when access validation succeeds', async () => {
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-4',
authProvider: 'oidc',
registrationStatus: 'approved',
});
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
const provider = new OIDCAuthProvider();
const result = await provider.validateAccess({ id: 'user-4', username: 'user', isAdmin: false, authProvider: 'oidc' });
expect(result).toBe(true);
});
it('returns false when access validation throws', async () => {
prismaMock.user.findUnique.mockRejectedValue(new Error('db down'));
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
const provider = new OIDCAuthProvider();
const result = await provider.validateAccess({ id: 'user-5', username: 'user', isAdmin: false, authProvider: 'oidc' });
expect(result).toBe(false);
});
it('expires old flow states during login', async () => {
vi.useFakeTimers();
const start = new Date('2024-01-01T00:00:00Z');
vi.setSystemTime(start);
setConfig({
'oidc.issuer_url': 'https://issuer',
'oidc.client_id': 'client',
'oidc.client_secret': 'secret',
});
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
// Make generators return different values for each call
const { generators } = await import('openid-client');
(generators.state as any)
.mockReturnValueOnce('state-1')
.mockReturnValueOnce('state-2');
(generators.nonce as any)
.mockReturnValueOnce('nonce-1')
.mockReturnValueOnce('nonce-2');
(generators.codeVerifier as any)
.mockReturnValueOnce('verifier-1')
.mockReturnValueOnce('verifier-2');
(generators.codeChallenge as any)
.mockReturnValueOnce('challenge-1')
.mockReturnValueOnce('challenge-2');
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
const provider = new OIDCAuthProvider();
const first = await provider.initiateLogin();
vi.setSystemTime(new Date(start.getTime() + 10 * 60 * 1000 + 1));
await provider.initiateLogin();
const result = await provider.handleCallback({ code: 'code', state: first.state });
expect(result.success).toBe(false);
expect(result.error).toMatch(/Invalid or expired state/i);
vi.useRealTimers();
});
});