Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
466 lines
14 KiB
TypeScript
466 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { NextRequest } from 'next/server';
|
|
|
|
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' | 'PATCH';
|
|
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: () => [],
|
|
},
|
|
{
|
|
// Reports album titles, where passwords live and the proxy topology.
|
|
name: 'GET /api/admin/doctor',
|
|
path: 'doctor',
|
|
load: () => import('../doctor/route'),
|
|
method: 'GET',
|
|
args: () => [new NextRequest('http://localhost/api/admin/doctor')],
|
|
},
|
|
{
|
|
// Lists album titles and raw asset UUIDs.
|
|
name: 'GET /api/admin/alt-text',
|
|
path: 'alt-text',
|
|
load: () => import('../alt-text/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: () => [],
|
|
},
|
|
{
|
|
// Names and email addresses of everyone who used the contact form.
|
|
name: 'GET /api/admin/messages',
|
|
path: 'messages',
|
|
load: () => import('../messages/route'),
|
|
method: 'GET',
|
|
args: () => [],
|
|
},
|
|
{
|
|
name: 'PATCH /api/admin/messages',
|
|
path: 'messages',
|
|
load: () => import('../messages/route'),
|
|
method: 'PATCH',
|
|
args: () => [
|
|
new NextRequest('http://localhost/api/admin/messages', { method: 'PATCH', body: '{}' }),
|
|
],
|
|
},
|
|
{
|
|
name: 'DELETE /api/admin/messages',
|
|
path: 'messages',
|
|
load: () => import('../messages/route'),
|
|
method: 'DELETE',
|
|
args: () => [new NextRequest('http://localhost/api/admin/messages?id=x', { method: 'DELETE' })],
|
|
},
|
|
{
|
|
name: 'GET /api/admin/privacy',
|
|
path: 'privacy',
|
|
load: () => import('../privacy/route'),
|
|
method: 'GET',
|
|
args: () => [],
|
|
},
|
|
{
|
|
name: 'PUT /api/admin/privacy',
|
|
path: 'privacy',
|
|
load: () => import('../privacy/route'),
|
|
method: 'PUT',
|
|
args: () => [new Request('http://localhost/api/admin/privacy', { method: 'PUT', body: '{}' })],
|
|
},
|
|
{
|
|
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' }) },
|
|
],
|
|
},
|
|
{
|
|
name: 'GET /api/admin/pages',
|
|
path: 'pages',
|
|
load: () => import('../pages/route'),
|
|
method: 'GET',
|
|
args: () => [],
|
|
},
|
|
{
|
|
name: 'POST /api/admin/pages',
|
|
path: 'pages',
|
|
load: () => import('../pages/route'),
|
|
method: 'POST',
|
|
args: () => [new Request('http://localhost/api/admin/pages', { method: 'POST', body: '{}' })],
|
|
},
|
|
{
|
|
name: 'GET /api/admin/pages/[slug]',
|
|
path: 'pages/[slug]',
|
|
load: () => import('../pages/[slug]/route'),
|
|
method: 'GET',
|
|
args: () => [
|
|
new Request('http://localhost/api/admin/pages/test-slug'),
|
|
{ params: Promise.resolve({ slug: 'test-slug' }) },
|
|
],
|
|
},
|
|
{
|
|
name: 'PUT /api/admin/pages/[slug]',
|
|
path: 'pages/[slug]',
|
|
load: () => import('../pages/[slug]/route'),
|
|
method: 'PUT',
|
|
args: () => [
|
|
new Request('http://localhost/api/admin/pages/test-slug', { method: 'PUT', body: '{}' }),
|
|
{ params: Promise.resolve({ slug: 'test-slug' }) },
|
|
],
|
|
},
|
|
{
|
|
name: 'DELETE /api/admin/pages/[slug]',
|
|
path: 'pages/[slug]',
|
|
load: () => import('../pages/[slug]/route'),
|
|
method: 'DELETE',
|
|
args: () => [
|
|
new Request('http://localhost/api/admin/pages/test-slug', { method: 'DELETE' }),
|
|
{ params: Promise.resolve({ slug: 'test-slug' }) },
|
|
],
|
|
},
|
|
{
|
|
name: 'GET /api/admin/proofing',
|
|
path: 'proofing',
|
|
load: () => import('../proofing/route'),
|
|
method: 'GET',
|
|
args: () => [new Request('http://localhost/api/admin/proofing')],
|
|
},
|
|
{
|
|
name: 'POST /api/admin/proofing',
|
|
path: 'proofing',
|
|
load: () => import('../proofing/route'),
|
|
method: 'POST',
|
|
args: () => [
|
|
new Request('http://localhost/api/admin/proofing', { method: 'POST', body: '{}' }),
|
|
],
|
|
},
|
|
{
|
|
name: 'GET /api/admin/proofing/[id]',
|
|
path: 'proofing/[id]',
|
|
load: () => import('../proofing/[id]/route'),
|
|
method: 'GET',
|
|
args: () => [
|
|
new Request('http://localhost/api/admin/proofing/abc'),
|
|
{ params: Promise.resolve({ id: 'abc' }) },
|
|
],
|
|
},
|
|
{
|
|
name: 'PATCH /api/admin/proofing/[id]',
|
|
path: 'proofing/[id]',
|
|
load: () => import('../proofing/[id]/route'),
|
|
method: 'PATCH',
|
|
args: () => [
|
|
new Request('http://localhost/api/admin/proofing/abc', { method: 'PATCH', body: '{}' }),
|
|
{ params: Promise.resolve({ id: 'abc' }) },
|
|
],
|
|
},
|
|
{
|
|
name: 'DELETE /api/admin/proofing/[id]',
|
|
path: 'proofing/[id]',
|
|
load: () => import('../proofing/[id]/route'),
|
|
method: 'DELETE',
|
|
args: () => [
|
|
new Request('http://localhost/api/admin/proofing/abc', { method: 'DELETE' }),
|
|
{ params: Promise.resolve({ id: 'abc' }) },
|
|
],
|
|
},
|
|
{
|
|
name: 'GET /api/admin/proofing/[id]/export',
|
|
path: 'proofing/[id]/export',
|
|
load: () => import('../proofing/[id]/export/route'),
|
|
method: 'GET',
|
|
args: () => [
|
|
new Request('http://localhost/api/admin/proofing/abc/export'),
|
|
{ params: Promise.resolve({ id: 'abc' }) },
|
|
],
|
|
},
|
|
];
|
|
|
|
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([]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The table above proves each route refuses an unauthenticated caller. This
|
|
* proves they all refuse it the *same way* — by going through withAdmin()
|
|
* rather than by carrying their own copy of the check, which is how the two
|
|
* refusals drifted apart in the first place.
|
|
*/
|
|
describe('admin routes are wrapped in withAdmin', () => {
|
|
const read = async () => {
|
|
const fs = await import('fs');
|
|
const path = await import('path');
|
|
const adminDir = path.join(process.cwd(), 'app/api/admin');
|
|
|
|
const files: { rel: string; src: 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') {
|
|
const rel = path.relative(adminDir, full).replace(/\\/g, '/');
|
|
// The login endpoint must stay reachable without a session.
|
|
if (rel.startsWith('auth/')) continue;
|
|
files.push({ rel, src: fs.readFileSync(full, 'utf8') });
|
|
}
|
|
}
|
|
};
|
|
walk(adminDir);
|
|
return files;
|
|
};
|
|
|
|
it('exports no handler outside the wrapper', async () => {
|
|
const offenders: string[] = [];
|
|
for (const { rel, src } of await read()) {
|
|
const bare = src.match(/export\s+(?:async\s+)?function\s+(GET|PUT|POST|DELETE|PATCH)\b/g);
|
|
if (bare) offenders.push(`${rel}: ${bare.join(', ')}`);
|
|
}
|
|
|
|
expect(
|
|
offenders,
|
|
`Admin route handlers not wrapped in withAdmin():\n${offenders.join('\n')}`,
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('never re-implements the guard by hand', async () => {
|
|
const offenders: string[] = [];
|
|
for (const { rel, src } of await read()) {
|
|
if (src.includes('isAdminEnabled') || src.includes('isAdminAuthenticated')) {
|
|
offenders.push(rel);
|
|
}
|
|
}
|
|
|
|
expect(
|
|
offenders,
|
|
`These call the auth helpers directly instead of using withAdmin():\n${offenders.join('\n')}`,
|
|
).toEqual([]);
|
|
});
|
|
});
|