Files
immich-folio/app/api/admin/__tests__/admin-guards.test.ts
Ralksta 6eda34cc50 release: sync main with dev (about editor, journal template, icon cleanup) (#449)
Brings `main` up to `dev` after #448 realigned the two branches.

`main` had #431 (portfolio features) but was missing everything that
landed on `dev` since:

- #443 — journal sample story ships as a template, not a live entry
- #445 — last emojis in the public frontend replaced with SVG icons
- #446 — About page toggle + content editor in the admin (@lancetm714's
work, rebased)
- #448 — the merge of `main` into `dev`, resolving the `Icons.tsx` split
and porting `IconEyeOff` into the shared icon file

After this merge both branches carry the same tree. CI on #448
(type-check + tests) passed on the merged state.

---------

Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
2026-08-14 21:19:54 +02:00

252 lines
7.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: vi.fn(),
isAdminAuthenticated: vi.fn(),
COOKIE_NAME: 'folio_admin_session',
}));
import { isAdminEnabled, isAdminAuthenticated } from '@/lib/admin/auth';
const mockEnabled = isAdminEnabled as unknown as ReturnType<typeof vi.fn>;
const mockAuthed = isAdminAuthenticated as unknown as ReturnType<typeof vi.fn>;
/**
* Every guarded admin route handler. Adding a new /api/admin route means
* adding a row here — a route that forgets its guard then fails this suite
* instead of shipping. /api/admin/auth is deliberately absent: it is the login
* endpoint and must stay reachable without a session.
*/
const ROUTES: {
name: string;
path: string;
load: () => Promise<Record<string, unknown>>;
method: 'GET' | 'PUT' | 'POST' | 'DELETE';
args: () => unknown[];
}[] = [
{
name: 'GET /api/admin/gallery',
path: 'gallery',
load: () => import('../gallery/route'),
method: 'GET',
args: () => [],
},
{
name: 'PUT /api/admin/gallery',
path: 'gallery',
load: () => import('../gallery/route'),
method: 'PUT',
args: () => [new Request('http://localhost/api/admin/gallery', { method: 'PUT', body: '{}' })],
},
{
name: 'GET /api/admin/settings',
path: 'settings',
load: () => import('../settings/route'),
method: 'GET',
args: () => [],
},
{
name: 'PUT /api/admin/settings',
path: 'settings',
load: () => import('../settings/route'),
method: 'PUT',
args: () => [new Request('http://localhost/api/admin/settings', { method: 'PUT', body: '{}' })],
},
{
name: 'GET /api/admin/albums',
path: 'albums',
load: () => import('../albums/route'),
method: 'GET',
args: () => [],
},
{
name: 'POST /api/admin/reload',
path: 'reload',
load: () => import('../reload/route'),
method: 'POST',
args: () => [],
},
{
name: 'GET /api/admin/status',
path: 'status',
load: () => import('../status/route'),
method: 'GET',
args: () => [],
},
{
name: 'GET /api/admin/assets',
path: 'assets',
load: () => import('../assets/route'),
method: 'GET',
args: () => [new Request('http://localhost/api/admin/assets')],
},
{
name: 'GET /api/admin/thumbnail/[id]',
path: 'thumbnail/[id]',
load: () => import('../thumbnail/[id]/route'),
method: 'GET',
args: () => [
new Request('http://localhost/api/admin/thumbnail/abc'),
{ params: Promise.resolve({ id: 'abc' }) },
],
},
{
name: 'GET /api/admin/albums/[albumId]/assets',
path: 'albums/[albumId]/assets',
load: () => import('../albums/[albumId]/assets/route'),
method: 'GET',
args: () => [
new Request('http://localhost/api/admin/albums/a1/assets'),
{ params: Promise.resolve({ albumId: 'a1' }) },
],
},
{
name: 'GET /api/admin/analytics',
path: 'analytics',
load: () => import('../analytics/route'),
method: 'GET',
args: () => [],
},
{
name: 'GET /api/admin/backups',
path: 'backups',
load: () => import('../backups/route'),
method: 'GET',
args: () => [],
},
{
name: 'POST /api/admin/backups',
path: 'backups',
load: () => import('../backups/route'),
method: 'POST',
args: () => [new Request('http://localhost/api/admin/backups', { method: 'POST', body: '{}' })],
},
{
name: 'PUT /api/admin/favicon',
path: 'favicon',
load: () => import('../favicon/route'),
method: 'PUT',
args: () => [new Request('http://localhost/api/admin/favicon', { method: 'PUT' })],
},
{
name: 'DELETE /api/admin/favicon',
path: 'favicon',
load: () => import('../favicon/route'),
method: 'DELETE',
args: () => [],
},
{
name: 'GET /api/admin/about',
path: 'about',
load: () => import('../about/route'),
method: 'GET',
args: () => [],
},
{
name: 'PUT /api/admin/about',
path: 'about',
load: () => import('../about/route'),
method: 'PUT',
args: () => [new Request('http://localhost/api/admin/about', { method: 'PUT', body: '{}' })],
},
{
name: 'GET /api/admin/journal',
path: 'journal',
load: () => import('../journal/route'),
method: 'GET',
args: () => [],
},
{
name: 'POST /api/admin/journal',
path: 'journal',
load: () => import('../journal/route'),
method: 'POST',
args: () => [new Request('http://localhost/api/admin/journal', { method: 'POST', body: '{}' })],
},
{
name: 'GET /api/admin/journal/[slug]',
path: 'journal/[slug]',
load: () => import('../journal/[slug]/route'),
method: 'GET',
args: () => [
new Request('http://localhost/api/admin/journal/test-slug'),
{ params: Promise.resolve({ slug: 'test-slug' }) },
],
},
{
name: 'PUT /api/admin/journal/[slug]',
path: 'journal/[slug]',
load: () => import('../journal/[slug]/route'),
method: 'PUT',
args: () => [
new Request('http://localhost/api/admin/journal/test-slug', { method: 'PUT', body: '{}' }),
{ params: Promise.resolve({ slug: 'test-slug' }) },
],
},
{
name: 'DELETE /api/admin/journal/[slug]',
path: 'journal/[slug]',
load: () => import('../journal/[slug]/route'),
method: 'DELETE',
args: () => [
new Request('http://localhost/api/admin/journal/test-slug', { method: 'DELETE' }),
{ params: Promise.resolve({ slug: 'test-slug' }) },
],
},
];
describe('admin route guards', () => {
beforeEach(() => {
vi.clearAllMocks();
});
for (const route of ROUTES) {
it(`${route.name} returns 403 when the admin panel is disabled`, async () => {
mockEnabled.mockReturnValue(false);
mockAuthed.mockResolvedValue(false);
const mod = await route.load();
const handler = mod[route.method] as (...a: unknown[]) => Promise<Response>;
const res = await handler(...route.args());
expect(res.status).toBe(403);
});
it(`${route.name} returns 401 without a valid session`, async () => {
mockEnabled.mockReturnValue(true);
mockAuthed.mockResolvedValue(false);
const mod = await route.load();
const handler = mod[route.method] as (...a: unknown[]) => Promise<Response>;
const res = await handler(...route.args());
expect(res.status).toBe(401);
});
}
it('has a row for every route module under app/api/admin', async () => {
const fs = await import('fs');
const path = await import('path');
const adminDir = path.join(process.cwd(), 'app/api/admin');
const found: string[] = [];
const walk = (dir: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === '__tests__') continue;
walk(full);
} else if (entry.name === 'route.ts') {
found.push(path.relative(adminDir, path.dirname(full)).replace(/\\/g, '/'));
}
}
};
walk(adminDir);
const covered = new Set(ROUTES.map((r) => r.path));
const uncovered = found.filter((p) => p !== 'auth' && !covered.has(p));
expect(uncovered, `Admin routes with no guard test: ${uncovered.join(', ')}`).toEqual([]);
});
});