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.
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
/**
|
|
* Component: Plex Auth API Route Tests
|
|
* Documentation: documentation/testing.md
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createPrismaMock } from '../helpers/prisma';
|
|
|
|
const prismaMock = createPrismaMock();
|
|
const plexServiceMock = vi.hoisted(() => ({
|
|
requestPin: vi.fn(),
|
|
getOAuthUrl: vi.fn(),
|
|
checkPin: vi.fn(),
|
|
getUserInfo: vi.fn(),
|
|
verifyServerAccess: vi.fn(),
|
|
getHomeUsers: vi.fn(),
|
|
switchHomeUser: vi.fn(),
|
|
}));
|
|
const encryptionServiceMock = vi.hoisted(() => ({
|
|
encrypt: vi.fn((value: string) => `enc-${value}`),
|
|
}));
|
|
const configServiceMock = vi.hoisted(() => ({
|
|
getPlexConfig: vi.fn(),
|
|
}));
|
|
const generateAccessTokenMock = vi.hoisted(() => vi.fn(() => 'access-token'));
|
|
const generateRefreshTokenMock = vi.hoisted(() => vi.fn(() => 'refresh-token'));
|
|
|
|
vi.mock('@/lib/db', () => ({
|
|
prisma: prismaMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/integrations/plex.service', () => ({
|
|
getPlexService: () => plexServiceMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/encryption.service', () => ({
|
|
getEncryptionService: () => encryptionServiceMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/config.service', () => ({
|
|
getConfigService: () => configServiceMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/utils/jwt', () => ({
|
|
generateAccessToken: generateAccessTokenMock,
|
|
generateRefreshToken: generateRefreshTokenMock,
|
|
}));
|
|
|
|
const makeRequest = (url: string, headers?: Record<string, string>) => ({
|
|
nextUrl: new URL(url),
|
|
headers: {
|
|
get: (key: string) => headers?.[key.toLowerCase()] || null,
|
|
},
|
|
json: vi.fn(),
|
|
});
|
|
|
|
describe('Plex auth routes', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('initiates Plex login and returns auth URL', async () => {
|
|
plexServiceMock.requestPin.mockResolvedValue({ id: 1, code: 'code-1' });
|
|
plexServiceMock.getOAuthUrl.mockReturnValue('http://plex/auth');
|
|
|
|
const { POST } = await import('@/app/api/auth/plex/login/route');
|
|
const response = await POST(makeRequest('http://localhost/api/auth/plex/login', { origin: 'http://app' }) as any);
|
|
const payload = await response.json();
|
|
|
|
expect(payload.authUrl).toBe('http://plex/auth');
|
|
expect(plexServiceMock.getOAuthUrl).toHaveBeenCalledWith('code-1', 1, 'http://app/api/auth/plex/callback');
|
|
});
|
|
|
|
it('returns 400 when pinId is missing', async () => {
|
|
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
|
|
|
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback') as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toBe('ValidationError');
|
|
});
|
|
|
|
it('returns 202 when waiting for authorization', async () => {
|
|
plexServiceMock.checkPin.mockResolvedValue(null);
|
|
|
|
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
|
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=1') as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(202);
|
|
expect(payload.authorized).toBe(false);
|
|
});
|
|
|
|
it('denies access when Plex server is not configured', async () => {
|
|
plexServiceMock.checkPin.mockResolvedValue('token');
|
|
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
|
|
configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: null, authToken: null });
|
|
|
|
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
|
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=2') as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(payload.error).toBe('ConfigurationError');
|
|
});
|
|
|
|
it('denies access when machine identifier is missing', async () => {
|
|
plexServiceMock.checkPin.mockResolvedValue('token');
|
|
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
|
|
configServiceMock.getPlexConfig.mockResolvedValue({
|
|
serverUrl: 'http://plex',
|
|
authToken: 'token',
|
|
machineIdentifier: null,
|
|
});
|
|
|
|
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
|
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=2') as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(payload.error).toBe('ConfigurationError');
|
|
});
|
|
|
|
it('rejects when user lacks server access', async () => {
|
|
plexServiceMock.checkPin.mockResolvedValue('token');
|
|
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
|
|
configServiceMock.getPlexConfig.mockResolvedValue({
|
|
serverUrl: 'http://plex',
|
|
authToken: 'token',
|
|
machineIdentifier: 'machine',
|
|
});
|
|
plexServiceMock.verifyServerAccess.mockResolvedValue(false);
|
|
|
|
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
|
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=2') as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(payload.error).toBe('AccessDenied');
|
|
});
|
|
|
|
it('returns errors when Plex user info is incomplete', async () => {
|
|
plexServiceMock.checkPin.mockResolvedValue('token');
|
|
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: '' });
|
|
|
|
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
|
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=2') as any);
|
|
const payload = await response.json();
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(payload.error).toBe('OAuthError');
|
|
expect(payload.details).toContain('Username is missing');
|
|
});
|
|
|
|
it('returns profile selection info when multiple home users exist', async () => {
|
|
plexServiceMock.checkPin.mockResolvedValue('token');
|
|
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
|
|
configServiceMock.getPlexConfig.mockResolvedValue({
|
|
serverUrl: 'http://plex',
|
|
authToken: 'token',
|
|
machineIdentifier: 'machine',
|
|
});
|
|
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
|
|
plexServiceMock.getHomeUsers.mockResolvedValue([{ id: 1 }, { id: 2 }]);
|
|
|
|
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
|
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=3', { accept: 'application/json' }) as any);
|
|
const payload = await response.json();
|
|
|
|
expect(payload.requiresProfileSelection).toBe(true);
|
|
expect(payload.homeUsers).toBe(2);
|
|
});
|
|
|
|
it('returns HTML redirect for browser profile selection', async () => {
|
|
plexServiceMock.checkPin.mockResolvedValue('token');
|
|
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
|
|
configServiceMock.getPlexConfig.mockResolvedValue({
|
|
serverUrl: 'http://plex',
|
|
authToken: 'token',
|
|
machineIdentifier: 'machine',
|
|
});
|
|
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
|
|
plexServiceMock.getHomeUsers.mockResolvedValue([{ id: 1 }, { id: 2 }]);
|
|
|
|
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
|
const response = await GET(
|
|
makeRequest('http://localhost/api/auth/plex/callback?pinId=3', {
|
|
accept: 'text/html',
|
|
host: 'example.com',
|
|
'x-forwarded-proto': 'https',
|
|
}) as any
|
|
);
|
|
const html = await response.text();
|
|
|
|
expect(response.headers.get('content-type')).toContain('text/html');
|
|
expect(html).toContain('sessionStorage.setItem');
|
|
expect(html).toContain('https://example.com/auth/select-profile?pinId=3');
|
|
});
|
|
|
|
it('returns tokens for successful Plex auth', async () => {
|
|
plexServiceMock.checkPin.mockResolvedValue('token');
|
|
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
|
|
configServiceMock.getPlexConfig.mockResolvedValue({
|
|
serverUrl: 'http://plex',
|
|
authToken: 'token',
|
|
machineIdentifier: 'machine',
|
|
});
|
|
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
|
|
plexServiceMock.getHomeUsers.mockResolvedValue([]);
|
|
prismaMock.user.count.mockResolvedValue(0);
|
|
prismaMock.user.upsert.mockResolvedValue({
|
|
id: 'user-1',
|
|
plexId: 'plex-1',
|
|
plexUsername: 'user',
|
|
plexEmail: null,
|
|
role: 'admin',
|
|
avatarUrl: null,
|
|
});
|
|
|
|
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
|
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=4', { accept: 'application/json' }) as any);
|
|
const payload = await response.json();
|
|
|
|
expect(payload.success).toBe(true);
|
|
expect(payload.accessToken).toBe('access-token');
|
|
});
|
|
|
|
it('returns HTML redirect with cookies for browser auth', async () => {
|
|
plexServiceMock.checkPin.mockResolvedValue('token');
|
|
plexServiceMock.getUserInfo.mockResolvedValue({
|
|
id: 'plex-1',
|
|
username: 'user',
|
|
email: 'user@example.com',
|
|
thumb: '/t',
|
|
});
|
|
configServiceMock.getPlexConfig.mockResolvedValue({
|
|
serverUrl: 'http://plex',
|
|
authToken: 'token',
|
|
machineIdentifier: 'machine',
|
|
});
|
|
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
|
|
plexServiceMock.getHomeUsers.mockResolvedValue([]);
|
|
prismaMock.user.count.mockResolvedValue(1);
|
|
prismaMock.user.upsert.mockResolvedValue({
|
|
id: 'user-1',
|
|
plexId: 'plex-1',
|
|
plexUsername: 'user',
|
|
plexEmail: 'user@example.com',
|
|
role: 'user',
|
|
avatarUrl: '/t',
|
|
});
|
|
|
|
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
|
const response = await GET(
|
|
makeRequest('http://localhost/api/auth/plex/callback?pinId=4', {
|
|
accept: 'text/html',
|
|
host: 'example.com',
|
|
'x-forwarded-proto': 'https',
|
|
}) as any
|
|
);
|
|
const html = await response.text();
|
|
|
|
expect(response.headers.get('content-type')).toContain('text/html');
|
|
expect(response.cookies.get('accessToken')?.value).toBe('access-token');
|
|
expect(response.cookies.get('refreshToken')?.value).toBe('refresh-token');
|
|
expect(html).toContain('#authData=');
|
|
});
|
|
|
|
it('returns Plex home users when token is provided', async () => {
|
|
plexServiceMock.getHomeUsers.mockResolvedValue([{ id: 1 }]);
|
|
|
|
const { GET } = await import('@/app/api/auth/plex/home-users/route');
|
|
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-token': 'token' }) as any);
|
|
const payload = await response.json();
|
|
|
|
expect(payload.success).toBe(true);
|
|
expect(payload.users).toHaveLength(1);
|
|
});
|
|
|
|
it('switches Plex profile using provided profile info', async () => {
|
|
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
|
|
prismaMock.user.count.mockResolvedValue(1);
|
|
prismaMock.user.upsert.mockResolvedValue({
|
|
id: 'user-2',
|
|
plexId: 'uuid-1',
|
|
plexUsername: 'Profile',
|
|
plexEmail: null,
|
|
role: 'user',
|
|
avatarUrl: null,
|
|
});
|
|
|
|
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
|
const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
|
|
request.json.mockResolvedValue({
|
|
userId: 'home-1',
|
|
pin: '1234',
|
|
profileInfo: { uuid: 'uuid-1', friendlyName: 'Profile' },
|
|
});
|
|
|
|
const response = await POST(request as any);
|
|
const payload = await response.json();
|
|
|
|
expect(payload.success).toBe(true);
|
|
expect(payload.accessToken).toBe('access-token');
|
|
});
|
|
});
|
|
|
|
|