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>
136 lines
4.4 KiB
TypeScript
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();
|
|
});
|
|
});
|