Files
immich-folio/app/api/admin/__tests__/settings-write.test.ts
Ralksta ad8b09700b 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>
2026-09-29 22:50:30 +02:00

136 lines
4.4 KiB
TypeScript

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 () => ({})),
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() }));
import { writeSettingsYaml } from '@/lib/admin/yaml-service';
import { PUT } from '../settings/route';
const put = (settings: unknown) =>
PUT(
new Request('http://localhost/api/admin/settings', {
method: 'PUT',
body: JSON.stringify({ settings }),
}),
);
/**
* The point of the check is not that a bad payload is reported — it is that
* nothing is written. settings.yaml is read by every public page, so a
* malformed save is discovered by visitors rather than by the person who made
* it (#599).
*/
describe('PUT /api/admin/settings', () => {
beforeEach(() => vi.clearAllMocks());
it('writes a valid payload', async () => {
const res = await put({ title: 'Folio', theme: 'minimal' });
expect(res.status).toBe(200);
expect(writeSettingsYaml).toHaveBeenCalledOnce();
});
it('leaves the file untouched when the payload is malformed', async () => {
const res = await put({ title: { de: 'Folio' }, grid: 3 });
expect(res.status).toBe(400);
expect(writeSettingsYaml).not.toHaveBeenCalled();
});
it('answers with the offending fields, so the panel can point at them', async () => {
const res = await put({ title: 1, theme: { fonts: { heading: 42 } } });
const body = await res.json();
expect(body.fields.map((f: { field: string }) => f.field).sort()).toEqual([
'theme.fonts.heading',
'title',
]);
});
it('still writes settings carrying keys this version does not know', async () => {
// Older and newer configs are both in the wild, and there is no
// schemaVersion to tell them apart.
const res = await put({ title: 'Folio', fromALaterRelease: { nested: true } });
expect(res.status).toBe(200);
expect(writeSettingsYaml).toHaveBeenCalledOnce();
});
});
/** The panel sends the site password as typed; the file must not keep it that way (#690). */
describe('PUT /api/admin/settings — site password', () => {
beforeEach(() => vi.clearAllMocks());
it('writes the site password as an scrypt hash', async () => {
const res = await put({ title: 'Folio', sitePassword: 'letmein' });
expect(res.status).toBe(200);
const written = vi.mocked(writeSettingsYaml).mock.calls[0][0] as { sitePassword: string };
expect(written.sitePassword).toMatch(/^scrypt:/);
expect(JSON.stringify(written)).not.toContain('letmein');
});
it('leaves an open site open', async () => {
await put({ title: 'Folio', sitePassword: '' });
const written = vi.mocked(writeSettingsYaml).mock.calls[0][0] as { sitePassword: string };
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();
});
});