mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Component: Plex 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(() => ({
|
||||
getPlexConfig: vi.fn(),
|
||||
}));
|
||||
const encryptionMock = vi.hoisted(() => ({
|
||||
encrypt: vi.fn((value: string) => `enc:${value}`),
|
||||
decrypt: vi.fn((value: string) => value.replace('enc:', '')),
|
||||
}));
|
||||
const plexServiceMock = vi.hoisted(() => ({
|
||||
requestPin: vi.fn(),
|
||||
getOAuthUrl: vi.fn(),
|
||||
checkPin: vi.fn(),
|
||||
getUserInfo: vi.fn(),
|
||||
verifyServerAccess: vi.fn(),
|
||||
getHomeUsers: 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('@/lib/integrations/plex.service', () => ({
|
||||
getPlexService: () => plexServiceMock,
|
||||
}));
|
||||
|
||||
describe('PlexAuthProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('initiates login and returns OAuth URL', async () => {
|
||||
process.env.PLEX_OAUTH_CALLBACK_URL = 'http://app/callback';
|
||||
plexServiceMock.requestPin.mockResolvedValue({ id: 42, code: 'CODE' });
|
||||
plexServiceMock.getOAuthUrl.mockReturnValue('http://plex/oauth');
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const result = await provider.initiateLogin();
|
||||
|
||||
expect(result.redirectUrl).toBe('http://plex/oauth');
|
||||
expect(result.pinId).toBe('42');
|
||||
});
|
||||
|
||||
it('returns error when PIN authorization is still pending', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue(null);
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const result = await provider.handleCallback({ pinId: '123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/waiting for user authorization/i);
|
||||
});
|
||||
|
||||
it('returns error when pinId is missing', async () => {
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const result = await provider.handleCallback({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/missing pin id/i);
|
||||
});
|
||||
|
||||
it('returns error when Plex server is not configured', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue('token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'user',
|
||||
authToken: 'token',
|
||||
});
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: null,
|
||||
authToken: 'token',
|
||||
libraryId: null,
|
||||
machineIdentifier: null,
|
||||
});
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const result = await provider.handleCallback({ pinId: '123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/plex server is not configured/i);
|
||||
});
|
||||
|
||||
it('returns profile selection when multiple home users are present', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue('token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'user',
|
||||
authToken: 'token',
|
||||
});
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
libraryId: 'lib',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
|
||||
plexServiceMock.getHomeUsers.mockResolvedValue([
|
||||
{ id: '1', title: 'User 1' },
|
||||
{ id: '2', title: 'User 2' },
|
||||
]);
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const result = await provider.handleCallback({ pinId: '123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.requiresProfileSelection).toBe(true);
|
||||
expect(result.profiles?.length).toBe(2);
|
||||
});
|
||||
|
||||
it('denies login when server access check fails', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue('token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'user',
|
||||
authToken: 'token',
|
||||
});
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
libraryId: 'lib',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
plexServiceMock.verifyServerAccess.mockResolvedValue(false);
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const result = await provider.handleCallback({ pinId: '123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/do not have access/i);
|
||||
});
|
||||
|
||||
it('creates user and returns tokens when auth succeeds', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue('token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'user',
|
||||
email: 'user@example.com',
|
||||
thumb: 'avatar',
|
||||
authToken: 'token',
|
||||
});
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
libraryId: 'lib',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
|
||||
plexServiceMock.getHomeUsers.mockResolvedValue([]);
|
||||
prismaMock.user.count.mockResolvedValue(1);
|
||||
prismaMock.user.upsert.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
plexUsername: 'user',
|
||||
plexEmail: 'user@example.com',
|
||||
avatarUrl: 'avatar',
|
||||
role: 'user',
|
||||
});
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const result = await provider.handleCallback({ pinId: '123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.tokens?.accessToken).toBeTruthy();
|
||||
expect(result.user?.authProvider).toBe('plex');
|
||||
});
|
||||
|
||||
it('returns false when access validation has no server config', async () => {
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: null,
|
||||
machineIdentifier: null,
|
||||
});
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const ok = await provider.validateAccess({ id: 'user-1', username: 'user', isAdmin: false, authProvider: 'plex' });
|
||||
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when Plex auth token is missing in the database', async () => {
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', authToken: null });
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const ok = await provider.validateAccess({ id: 'user-1', username: 'user', isAdmin: false, authProvider: 'plex' });
|
||||
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it('decrypts tokens and verifies server access', async () => {
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', authToken: 'enc:token' });
|
||||
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const ok = await provider.validateAccess({ id: 'user-1', username: 'user', isAdmin: false, authProvider: 'plex' });
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:token');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user