release: v0.19.0

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>
This commit is contained in:
Ralksta
2026-09-29 22:50:30 +02:00
committed by GitHub
parent 5f251923f0
commit ad8b09700b
297 changed files with 19398 additions and 1800 deletions

View File

@@ -248,6 +248,106 @@ const ROUTES: {
{ 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', () => {

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: () => true,
isAdminAuthenticated: async () => true,
COOKIE_NAME: 'folio_admin_session',
}));
vi.mock('@/lib/config', () => ({
getConfig: vi.fn(() => ({
needsCredentials: false,
immich: { apiUrl: 'http://immich.test/api', apiKey: 'key' },
})),
}));
import { GET } from '../assets/route';
type Item = { id: string; originalFileName: string; fileCreatedAt: string; isFavorite: boolean };
const item = (id: string): Item => ({
id,
originalFileName: `${id}.jpg`,
fileCreatedAt: '2024-05-17T10:00:00.000Z',
isFavorite: false,
});
let fetchMock: ReturnType<typeof vi.fn>;
const bodies = () =>
fetchMock.mock.calls.map(([, init]) => JSON.parse((init as RequestInit).body as string));
function respond(...pages: Array<{ items: Item[]; nextPage: string | null }>) {
for (const p of pages) {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ assets: p }), { status: 200 }));
}
}
async function get(query: string) {
const res = await GET(new NextRequest(`http://localhost/api/admin/assets?${query}`));
return { status: res.status, body: await res.json() };
}
describe('GET /api/admin/assets — search (#602)', () => {
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => vi.unstubAllGlobals());
it('browses without a query in one Immich call', async () => {
respond({ items: [item('a')], nextPage: '2' });
const { status, body } = await get('page=1&favorites=true');
expect(status).toBe(200);
expect(body).toEqual({ assets: [item('a')], nextPage: 2 });
expect(bodies()).toEqual([expect.objectContaining({ page: 1, isFavorite: true })]);
expect(bodies()[0]).not.toHaveProperty('originalFileName');
});
it('searches filename and description and merges without duplicates', async () => {
respond(
{ items: [item('a'), item('b')], nextPage: null },
{ items: [item('b'), item('c')], nextPage: '2' },
);
const { body } = await get('page=1&q=%20harbour%20');
expect(bodies()).toEqual([
expect.objectContaining({ originalFileName: 'harbour' }),
expect.objectContaining({ description: 'harbour' }),
]);
expect(body.assets.map((a: Item) => a.id)).toEqual(['a', 'b', 'c']);
expect(body.nextPage).toBe(2);
});
it.each([
['2024', '2024-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z'],
['2024-02', '2024-02-01T00:00:00.000Z', '2024-03-01T00:00:00.000Z'],
['2024-02-29', '2024-02-29T00:00:00.000Z', '2024-03-01T00:00:00.000Z'],
])('treats %s as a taken-date range', async (q, after, before) => {
respond({ items: [], nextPage: null });
await get(`q=${q}`);
expect(bodies()).toEqual([expect.objectContaining({ takenAfter: after, takenBefore: before })]);
});
it('falls back to text search for an impossible date', async () => {
respond({ items: [], nextPage: null }, { items: [], nextPage: null });
await get('q=2023-02-29');
expect(bodies()[0]).toMatchObject({ originalFileName: '2023-02-29' });
});
it.each(['0', '-1', 'abc', '1.5', '99999', '1e3'])('rejects page=%s', async (page) => {
const { status } = await get(`page=${page}`);
expect(status).toBe(400);
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects an overlong query before calling Immich', async () => {
const { status } = await get(`q=${'x'.repeat(101)}`);
expect(status).toBe(400);
expect(fetchMock).not.toHaveBeenCalled();
});
it('maps an Immich error to 502', async () => {
fetchMock.mockResolvedValue(new Response('nope', { status: 500 }));
const { status } = await get('q=x');
expect(status).toBe(502);
});
});

View File

@@ -0,0 +1,236 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises';
/**
* Every content editor's route (#601): GET hands out a version, a PUT with a
* stale `If-Match` is answered 409 with the current version and writes
* nothing, a matching one saves and returns the new version, and a PUT with no
* `If-Match` at all — an admin tab from before the upgrade — still saves.
*
* Real services on a temp content/ directory; the routes resolve it from
* process.cwd() at import, so they are imported after cwd is pointed there.
*/
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: vi.fn(() => true),
isAdminAuthenticated: vi.fn(async () => true),
COOKIE_NAME: 'folio_admin_session',
}));
vi.mock('@/lib/config', () => ({
invalidateConfigCache: vi.fn(),
getConfigOrNull: vi.fn(() => null),
getConfig: vi.fn(() => ({
privacy: { enabled: true },
subpages: [],
albumPasswords: {},
lang: 'en',
})),
deriveGallery: vi.fn(() => ({ subpages: [] })),
isPageRef: (e: unknown) => !!e && typeof e === 'object' && 'page' in e,
}));
vi.mock('@/lib/privacy', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/privacy')>()),
processingFacts: vi.fn(() => []),
starterHeadings: vi.fn(() => ''),
}));
vi.mock('@/lib/admin/pageSlugs', () => ({
takenPageSlugs: vi.fn(async () => ({ subpages: [], albums: [], journal: [] })),
}));
vi.mock('@/lib/immich', () => ({ immich: { invalidateAll: vi.fn() } }));
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
let root: string;
let content: string;
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'folio-conflict-'));
content = path.join(root, 'content');
await mkdir(path.join(content, 'journal'), { recursive: true });
await mkdir(path.join(content, 'pages'), { recursive: true });
vi.spyOn(process, 'cwd').mockReturnValue(root);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.resetModules();
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(root, { recursive: true, force: true });
});
const req = (method: string, body?: unknown, ifMatch?: string) =>
new Request('http://localhost/api/admin/x', {
method,
headers: {
'Content-Type': 'application/json',
...(ifMatch === undefined ? {} : { 'If-Match': ifMatch }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
type Handler = (request: Request, ctx?: never) => Promise<Response>;
interface Case {
name: string;
file: string;
initial: string;
load: () => Promise<{ GET: Handler; PUT: Handler }>;
ctx?: unknown;
body: (tag: string) => unknown;
}
const md = (title: string) => `---\ntitle: "${title}"\n---\n\nBody of ${title}\n`;
const slugCtx = (slug: string) => ({ params: Promise.resolve({ slug }) });
const cases: Case[] = [
{
name: 'settings',
file: 'settings.yaml',
initial: 'title: Start\n',
load: () => import('../settings/route'),
body: (tag) => ({ settings: { title: tag } }),
},
{
name: 'gallery',
file: 'gallery.yaml',
initial: 'hero: []\nalbums: []\nsubpages: []\n',
load: () => import('../gallery/route'),
body: (tag) => ({ gallery: { hero: [], albums: [tag], subpages: [] } }),
},
{
name: 'about',
file: 'about.md',
initial: '---\nname: Start\n---\n\nHello\n',
load: () => import('../about/route'),
body: (tag) => ({ meta: { name: tag }, body: 'Hello' }),
},
{
name: 'privacy',
file: 'privacy.md',
initial: 'Start\n',
load: () => import('../privacy/route'),
body: (tag) => ({ body: tag }),
},
{
name: 'journal entry',
file: 'journal/trip.md',
initial: md('Start'),
load: () => import('../journal/[slug]/route') as never,
ctx: slugCtx('trip'),
body: (tag) => ({ rawMarkdown: md(tag) }),
},
{
name: 'content page',
file: 'pages/faq.md',
initial: md('Start'),
load: () => import('../pages/[slug]/route') as never,
ctx: slugCtx('faq'),
body: (tag) => ({ rawMarkdown: md(tag) }),
},
];
describe.each(cases)('$name route (#601)', ({ file, initial, load, ctx, body }) => {
const call = (h: Handler, r: Request) => h(r, ctx as never);
it('GET returns a version and ETag; a matching If-Match saves and returns the next one', async () => {
await writeFile(path.join(content, file), initial);
const { GET, PUT } = await load();
const got = await call(GET, req('GET'));
const { version } = await got.json();
expect(typeof version).toBe('string');
expect(got.headers.get('ETag')).toBe(`"${version}"`);
const saved = await call(PUT, req('PUT', body('Mine'), `"${version}"`));
expect(saved.status).toBe(200);
const data = await saved.json();
expect(data.version).toMatch(/^[0-9a-f]{32}$/);
expect(data.version).not.toBe(version);
});
it('a stale If-Match gets 409 with the current version and writes nothing', async () => {
await writeFile(path.join(content, file), initial);
const { GET, PUT } = await load();
const { version: stale } = await (await call(GET, req('GET'))).json();
// Another tab saves in between.
const other = await call(PUT, req('PUT', body('Theirs'), `"${stale}"`));
expect(other.status).toBe(200);
const { version: current } = await other.json();
const onDisk = await readFile(path.join(content, file), 'utf8');
const res = await call(PUT, req('PUT', body('Mine'), `"${stale}"`));
expect(res.status).toBe(409);
const data = await res.json();
expect(data).toMatchObject({ conflict: true, version: current });
expect(res.headers.get('ETag')).toBe(`"${current}"`);
await expect(readFile(path.join(content, file), 'utf8')).resolves.toBe(onDisk);
// "Overwrite anyway" resends with the version the 409 named.
const forced = await call(PUT, req('PUT', body('Mine'), `"${current}"`));
expect(forced.status).toBe(200);
});
it('a file that is not valid UTF-8 saves with the version GET handed out', async () => {
// A hand edit in Latin-1: "Über" as 0xDC, which UTF-8 cannot decode. The
// decoded text re-encodes to other bytes, so a version taken from it never
// matched the save's check and every save was a 409.
const latin1 = Buffer.concat([
Buffer.from(initial),
Buffer.from('\n# '),
Buffer.from([0xdc]),
Buffer.from('ber\n'),
]);
await writeFile(path.join(content, file), latin1);
const { GET, PUT } = await load();
const { version } = await (await call(GET, req('GET'))).json();
const res = await call(PUT, req('PUT', body('Mine'), `"${version}"`));
expect(res.status).toBe(200);
});
it('a PUT without If-Match still saves (older clients)', async () => {
await writeFile(path.join(content, file), initial);
const { PUT } = await load();
const res = await call(PUT, req('PUT', body('Mine')));
expect(res.status).toBe(200);
await expect(readFile(path.join(content, file), 'utf8')).resolves.not.toBe(initial);
});
});
describe('renames (#601)', () => {
it('a journal rename is checked against the entry it was loaded from', async () => {
await writeFile(path.join(content, 'journal/trip.md'), md('Start'));
const { GET, PUT } = await import('../journal/[slug]/route');
const ctx = slugCtx('trip');
const { version } = await (await GET(req('GET'), ctx)).json();
await writeFile(path.join(content, 'journal/trip.md'), md('Hand edit'));
const res = await PUT(req('PUT', { rawMarkdown: md('Mine'), newSlug: 'voyage' }, version), ctx);
expect(res.status).toBe(409);
await expect(readFile(path.join(content, 'journal/trip.md'), 'utf8')).resolves.toBe(
md('Hand edit'),
);
await expect(readFile(path.join(content, 'journal/voyage.md'))).rejects.toThrow();
});
it('a page rename reports the gallery.yaml version change it made', async () => {
await writeFile(path.join(content, 'pages/faq.md'), md('FAQ'));
await writeFile(path.join(content, 'gallery.yaml'), 'subpages:\n - page: faq\n');
const gallery = await import('../gallery/route');
const { version: galleryBefore } = await (await gallery.GET()).json();
const { GET, PUT } = await import('../pages/[slug]/route');
const ctx = slugCtx('faq');
const { version } = await (await GET(req('GET'), ctx)).json();
const res = await PUT(req('PUT', { rawMarkdown: md('FAQ'), newSlug: 'help' }, version), ctx);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.menuRenamed).toBe(true);
expect(data.galleryVersion.from).toBe(galleryBefore);
const { version: galleryAfter } = await (await gallery.GET()).json();
expect(data.galleryVersion.to).toBe(galleryAfter);
});
});

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises';
/**
* A page rename or delete moves the file first and then follows up the menu
* reference in gallery.yaml (#722). When that follow-up fails — gallery.yaml
* edited by hand into invalid YAML, a write error — the page has already been
* renamed or deleted. Answering 500 then told the admin nothing was saved: the
* panel kept the old slug, and every retry hit a 404 for a file that was gone.
*
* Real services on a temp content/ directory, as in concurrent-edits.test.ts.
*/
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: vi.fn(() => true),
isAdminAuthenticated: vi.fn(async () => true),
COOKIE_NAME: 'folio_admin_session',
}));
vi.mock('@/lib/config', () => ({
invalidateConfigCache: vi.fn(),
isPageRef: (e: unknown) => !!e && typeof e === 'object' && 'page' in e,
}));
vi.mock('@/lib/admin/pageSlugs', () => ({
takenPageSlugs: vi.fn(async () => ({ subpages: [], albums: [], journal: [] })),
}));
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
let root: string;
let content: string;
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'folio-page-refs-'));
content = path.join(root, 'content');
await mkdir(path.join(content, 'pages'), { recursive: true });
vi.spyOn(process, 'cwd').mockReturnValue(root);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.resetModules();
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(root, { recursive: true, force: true });
});
const req = (method: string, body?: unknown) =>
new Request('http://localhost/api/admin/pages/x', {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const md = (title: string) => `---\ntitle: "${title}"\n---\n\nBody of ${title}\n`;
const slugCtx = (slug: string) => ({ params: Promise.resolve({ slug }) });
const BROKEN_YAML = 'subpages:\n - page: faq\n bad: [unclosed\n';
describe('page rename when the menu cannot be updated', () => {
it('reports the rename as done, with a warning about the menu', async () => {
await writeFile(path.join(content, 'pages/faq.md'), md('FAQ'));
await writeFile(path.join(content, 'gallery.yaml'), BROKEN_YAML);
const { PUT } = await import('../pages/[slug]/route');
const res = await PUT(req('PUT', { rawMarkdown: md('FAQ'), newSlug: 'help' }), slugCtx('faq'));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
expect(data.page.slug).toBe('help');
expect(data.menuRenamed).toBe(false);
expect(data.galleryVersion).toBeNull();
expect(data.warning).toMatch(/gallery\.yaml/);
// The rename itself happened, and gallery.yaml was left alone.
await expect(readFile(path.join(content, 'pages/help.md'), 'utf8')).resolves.toBe(md('FAQ'));
await expect(readFile(path.join(content, 'pages/faq.md'))).rejects.toThrow();
await expect(readFile(path.join(content, 'gallery.yaml'), 'utf8')).resolves.toBe(BROKEN_YAML);
});
it('a plain save does not touch the menu and carries no warning', async () => {
await writeFile(path.join(content, 'pages/faq.md'), md('FAQ'));
await writeFile(path.join(content, 'gallery.yaml'), BROKEN_YAML);
const { PUT } = await import('../pages/[slug]/route');
const res = await PUT(req('PUT', { rawMarkdown: md('FAQ 2') }), slugCtx('faq'));
expect(res.status).toBe(200);
expect((await res.json()).warning).toBeUndefined();
});
});
describe('page delete when the menu cannot be updated', () => {
it('reports the delete as done, with a warning about the menu', async () => {
await writeFile(path.join(content, 'pages/faq.md'), md('FAQ'));
await writeFile(path.join(content, 'gallery.yaml'), BROKEN_YAML);
const { DELETE } = await import('../pages/[slug]/route');
const res = await DELETE(req('DELETE'), slugCtx('faq'));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
expect(data.deletedSlug).toBe('faq');
expect(data.removedFromMenu).toBe(false);
expect(data.galleryVersion).toBeNull();
expect(data.warning).toMatch(/gallery\.yaml/);
await expect(readFile(path.join(content, 'pages/faq.md'))).rejects.toThrow();
});
});

View File

@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: vi.fn(() => true),
isAdminAuthenticated: vi.fn(async () => true),
COOKIE_NAME: 'folio_admin_session',
}));
vi.mock('@/lib/admin/yaml-service', () => ({
readSettingsYaml: vi.fn(async () => ({
sitePassword: 'scrypt:stored:hash',
contact: { enabled: true, notifyUrl: 'https://ntfy.sh/stored' },
})),
readSettingsYamlVersioned: vi.fn(async () => ({
data: {
sitePassword: 'scrypt:stored:hash',
contact: { enabled: true, notifyUrl: 'https://ntfy.sh/stored' },
},
version: 'v1',
})),
writeSettingsYaml: vi.fn(async () => undefined),
}));
vi.mock('@/lib/config', () => ({
invalidateConfigCache: vi.fn(),
getConfigOrNull: vi.fn(() => null),
}));
vi.mock('@/lib/immich', () => ({ immich: { invalidateAll: vi.fn() } }));
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
const env = vi.hoisted(() => ({
SITE_PASSWORD: 'env-secret-password' as string | undefined,
CONTACT_NOTIFY_URL: 'https://ntfy.sh/env-secret-topic' as string | undefined,
SITE_TITLE: 'Env Title',
}));
vi.mock('@/lib/env', () => ({ env }));
import { writeSettingsYaml } from '@/lib/admin/yaml-service';
import { GET, PUT } from '../settings/route';
/**
* The panel renders these fields locked (#605). The payload carries only the
* variable names: the values are a password and an ntfy topic.
*/
describe('/api/admin/settings env locks (#605)', () => {
beforeEach(() => {
vi.clearAllMocks();
env.SITE_PASSWORD = 'env-secret-password';
env.CONTACT_NOTIFY_URL = 'https://ntfy.sh/env-secret-topic';
});
it('GET names the locking variables and never their values', async () => {
const res = await GET();
const text = await res.text();
expect(JSON.parse(text).envLocks).toEqual({
sitePassword: 'SITE_PASSWORD',
'contact.notifyUrl': 'CONTACT_NOTIFY_URL',
});
expect(text).not.toContain('env-secret-password');
expect(text).not.toContain('env-secret-topic');
});
it('GET reports no locks when the variables are unset', async () => {
env.SITE_PASSWORD = undefined;
env.CONTACT_NOTIFY_URL = undefined;
const body = await (await GET()).json();
expect(body.envLocks).toEqual({});
});
it('PUT keeps the stored value of a locked field', async () => {
const res = await PUT(
new Request('http://localhost/api/admin/settings', {
method: 'PUT',
body: JSON.stringify({
settings: {
title: 'Folio',
sitePassword: 'typed-in-panel',
contact: { enabled: true, notifyUrl: 'https://ntfy.sh/typed' },
},
}),
}),
);
expect(res.status).toBe(200);
expect(vi.mocked(writeSettingsYaml).mock.calls[0][0]).toMatchObject({
title: 'Folio',
sitePassword: 'scrypt:stored:hash',
contact: { enabled: true, notifyUrl: 'https://ntfy.sh/stored' },
});
});
});

View File

@@ -95,3 +95,41 @@ describe('PUT /api/admin/settings — site password', () => {
expect(written.sitePassword).toBe('');
});
});
/**
* Values the site would ignore used to be written with a success message: the
* accent fell back to the preset, the columns were clamped, the sitemap went
* out without an address (QA A-14).
*/
describe('PUT /api/admin/settings — values the site would ignore', () => {
beforeEach(() => vi.clearAllMocks());
it('refuses them, names each field, and writes nothing', async () => {
const res = await put({
url: 'folio.example',
theme: { preset: 'minimal', accent: 'rot' },
grid: { columns: 9, gap: 12 },
});
const body = await res.json();
expect(res.status).toBe(400);
expect(writeSettingsYaml).not.toHaveBeenCalled();
expect(body.fields.map((f: { field: string }) => f.field).sort()).toEqual([
'grid.columns',
'theme.accent',
'url',
]);
expect(body.fields.every((f: { message: string }) => f.message.length > 0)).toBe(true);
});
it('writes the same fields once they hold values the site uses', async () => {
const res = await put({
url: 'https://folio.example',
theme: { preset: 'minimal', accent: '#e60012' },
grid: { columns: 6, gap: 0 },
});
expect(res.status).toBe(200);
expect(writeSettingsYaml).toHaveBeenCalledOnce();
});
});