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>
252 lines
7.2 KiB
TypeScript
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([]);
|
|
});
|
|
});
|