Files
ReadMeABook/tests/api/admin-api-tokens.routes.test.ts
T
2026-03-04 16:57:02 -08:00

216 lines
6.8 KiB
TypeScript

/**
* Component: Admin API Tokens Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
// Valid UUIDs for testing
const ADMIN_ID = '11111111-1111-1111-1111-111111111111';
const USER_ID = '22222222-2222-2222-2222-222222222222';
const ADMIN2_ID = '33333333-3333-3333-3333-333333333333';
const NONEXISTENT_ID = '99999999-9999-9999-9999-999999999999';
let authRequest: any;
const prismaMock = createPrismaMock();
const requireAuthMock = vi.hoisted(() => vi.fn());
const requireAdminMock = vi.hoisted(() => vi.fn());
const checkApiTokenCreateRateLimitMock = vi.hoisted(() => vi.fn());
const generateApiTokenMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/utils/apiTokenRateLimit', () => ({
checkApiTokenCreateRateLimit: checkApiTokenCreateRateLimitMock,
}));
vi.mock('@/lib/utils/api-token', () => ({
generateApiToken: generateApiTokenMock,
}));
describe('Admin API tokens routes', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = {
user: { id: ADMIN_ID, username: 'admin', role: 'admin' },
json: vi.fn(),
};
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
checkApiTokenCreateRateLimitMock.mockReturnValue({ allowed: true });
generateApiTokenMock.mockReturnValue({
fullToken: 'rmab_test_full_token',
tokenHash: 'hashed_token',
tokenPrefix: 'rmab_test',
});
});
describe('POST /api/admin/api-tokens', () => {
it('creates token for self with own role when no userId specified', async () => {
authRequest.json.mockResolvedValueOnce({ name: 'Test Token' });
prismaMock.user.findUnique.mockResolvedValueOnce({
id: ADMIN_ID,
role: 'admin',
plexUsername: 'admin',
});
prismaMock.apiToken.count.mockResolvedValueOnce(0);
prismaMock.apiToken.create.mockResolvedValueOnce({
id: 'token-1',
name: 'Test Token',
tokenPrefix: 'rmab_test',
role: 'admin',
expiresAt: null,
createdAt: new Date(),
});
const { POST } = await import('@/app/api/admin/api-tokens/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(201);
expect(payload.token.role).toBe('admin');
expect(payload.fullToken).toBe('rmab_test_full_token');
});
it('creates token for another user with their role', async () => {
authRequest.json.mockResolvedValueOnce({
name: 'Token for User',
userId: USER_ID,
});
prismaMock.user.findUnique.mockResolvedValueOnce({
id: USER_ID,
role: 'user',
plexUsername: 'regularuser',
});
prismaMock.apiToken.count.mockResolvedValueOnce(0);
prismaMock.apiToken.create.mockResolvedValueOnce({
id: 'token-2',
name: 'Token for User',
tokenPrefix: 'rmab_test',
role: 'user',
expiresAt: null,
createdAt: new Date(),
});
const { POST } = await import('@/app/api/admin/api-tokens/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(201);
expect(payload.token.role).toBe('user');
});
it('rejects role override when role differs from target user role', async () => {
authRequest.json.mockResolvedValueOnce({
name: 'Escalation Attempt',
userId: USER_ID,
role: 'admin', // Trying to give admin role to a regular user
});
prismaMock.user.findUnique.mockResolvedValueOnce({
id: USER_ID,
role: 'user', // Target user is actually a regular user
plexUsername: 'regularuser',
});
const { POST } = await import('@/app/api/admin/api-tokens/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toContain("must match target user's role");
});
it('rejects role downgrade when role differs from target user role', async () => {
authRequest.json.mockResolvedValueOnce({
name: 'Downgrade Attempt',
userId: ADMIN2_ID,
role: 'user', // Trying to give user role to an admin
});
prismaMock.user.findUnique.mockResolvedValueOnce({
id: ADMIN2_ID,
role: 'admin', // Target user is actually an admin
plexUsername: 'otheradmin',
});
const { POST } = await import('@/app/api/admin/api-tokens/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toContain("must match target user's role");
});
it('accepts role when it matches target user role', async () => {
authRequest.json.mockResolvedValueOnce({
name: 'Matching Role',
userId: USER_ID,
role: 'user', // Explicitly specifying role that matches
});
prismaMock.user.findUnique.mockResolvedValueOnce({
id: USER_ID,
role: 'user',
plexUsername: 'regularuser',
});
prismaMock.apiToken.count.mockResolvedValueOnce(0);
prismaMock.apiToken.create.mockResolvedValueOnce({
id: 'token-3',
name: 'Matching Role',
tokenPrefix: 'rmab_test',
role: 'user',
expiresAt: null,
createdAt: new Date(),
});
const { POST } = await import('@/app/api/admin/api-tokens/route');
const response = await POST({} as any);
expect(response.status).toBe(201);
});
it('returns 404 when target user does not exist', async () => {
authRequest.json.mockResolvedValueOnce({
name: 'Token for Ghost',
userId: NONEXISTENT_ID,
});
prismaMock.user.findUnique.mockResolvedValueOnce(null);
const { POST } = await import('@/app/api/admin/api-tokens/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toBe('Target user not found');
});
it('returns 429 when rate limited', async () => {
checkApiTokenCreateRateLimitMock.mockReturnValueOnce({
allowed: false,
retryAfterSeconds: 60,
});
authRequest.json.mockResolvedValueOnce({ name: 'Rate Limited Token' });
const { POST } = await import('@/app/api/admin/api-tokens/route');
const response = await POST({} as any);
expect(response.status).toBe(429);
expect(response.headers.get('Retry-After')).toBe('60');
});
});
});