release: v0.16.0
See CHANGELOG.md for the full v0.16.0 entry. --------- 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>
This commit is contained in:
68
app/api/admin/__tests__/about-backup.test.ts
Normal file
68
app/api/admin/__tests__/about-backup.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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('next/cache', () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
// The route imports from 'fs' directly; atomicWrite (used for the actual
|
||||
// write) imports from 'fs/promises'. Both need the same mock functions so a
|
||||
// test can see calls made through either.
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
access: vi.fn(async () => undefined),
|
||||
copyFile: vi.fn(async () => undefined),
|
||||
readdir: vi.fn(async () => []),
|
||||
unlink: vi.fn(async () => undefined),
|
||||
writeFile: vi.fn(async () => undefined),
|
||||
rename: vi.fn(async () => undefined),
|
||||
readFile: vi.fn(async () => {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
}),
|
||||
}));
|
||||
vi.mock('fs', () => ({ promises: fsMock }));
|
||||
vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock }));
|
||||
|
||||
const fs = fsMock;
|
||||
import { PUT } from '../about/route';
|
||||
|
||||
const put = (body: unknown) =>
|
||||
PUT(
|
||||
new Request('http://localhost/api/admin/about', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* "No file yet" and "the backup could not be written" used to share one
|
||||
* catch, so a `.backups/` this save couldn't write to looked exactly like a
|
||||
* brand-new file and the save went ahead with no snapshot (#630).
|
||||
*/
|
||||
describe('PUT /api/admin/about backup handling', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('saves a brand-new file with no backup', async () => {
|
||||
vi.mocked(fs.access).mockRejectedValueOnce(
|
||||
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
||||
);
|
||||
|
||||
const res = await put({ meta: { name: 'Me' }, body: 'Hello' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(fs.copyFile).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('aborts the save when the file exists but the backup copy fails', async () => {
|
||||
vi.mocked(fs.copyFile).mockRejectedValueOnce(
|
||||
Object.assign(new Error('EACCES'), { code: 'EACCES' }),
|
||||
);
|
||||
|
||||
await expect(put({ meta: { name: 'Me' }, body: 'Hello' })).rejects.toThrow('EACCES');
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -266,3 +266,62 @@ describe('admin route guards', () => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,18 +14,28 @@ vi.mock('@/lib/admin/auth', () => ({
|
||||
COOKIE_NAME: 'folio_admin_session',
|
||||
}));
|
||||
|
||||
const baseConfig = {
|
||||
needsCredentials: true,
|
||||
trustedProxyHops: 0,
|
||||
authSecret: 'x'.repeat(64),
|
||||
albums: [] as string[],
|
||||
standaloneAlbums: [] as string[],
|
||||
albumOverrides: {} as Record<string, string>,
|
||||
subpages: [] as Array<{ name: string; albumIds: string[] }>,
|
||||
albumPasswords: {},
|
||||
sitePassword: '',
|
||||
immich: { apiUrl: '', apiKey: '' },
|
||||
immichTimeoutMs: 1000,
|
||||
};
|
||||
const getConfigMock = vi.fn(() => baseConfig);
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => ({
|
||||
needsCredentials: true,
|
||||
trustedProxyHops: 0,
|
||||
authSecret: 'x'.repeat(64),
|
||||
albums: [],
|
||||
subpages: [],
|
||||
albumPasswords: {},
|
||||
sitePassword: '',
|
||||
immich: { apiUrl: '', apiKey: '' },
|
||||
immichTimeoutMs: 1000,
|
||||
}),
|
||||
getConfig: () => getConfigMock(),
|
||||
slugify: (name: string) =>
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, ''),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/env', () => ({ env: { AUTH_SECRET: 'x'.repeat(64) } }));
|
||||
@@ -89,3 +99,119 @@ describe('GET /api/admin/doctor', () => {
|
||||
expect(proxy.level).toBe('warn');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `if (albums.length)` used to skip the album check entirely when Immich
|
||||
* answered with an empty list — an API key regenerated under a different
|
||||
* account, say — so every connection check passed while every album page was
|
||||
* silently empty (#629).
|
||||
*/
|
||||
describe('GET /api/admin/doctor — album checks', () => {
|
||||
const ALBUM_ID = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
function withCredentialsAndAlbums(albums: string[]) {
|
||||
getConfigMock.mockReturnValueOnce({
|
||||
...baseConfig,
|
||||
needsCredentials: false,
|
||||
albums,
|
||||
immich: { apiUrl: 'https://immich.example', apiKey: 'key' },
|
||||
});
|
||||
}
|
||||
|
||||
it('reports every configured album as missing when Immich returns an empty list', async () => {
|
||||
withCredentialsAndAlbums([ALBUM_ID]);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
if (url.includes('/albums')) return { ok: true, json: async () => [] } as Response;
|
||||
return { ok: true, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
const body = await (await GET(req())).json();
|
||||
const albumFinding = body.findings.find((f: { id: string }) => f.id === 'album-ids');
|
||||
|
||||
expect(albumFinding?.level).toBe('error');
|
||||
expect(albumFinding?.title).toContain('1 of 1');
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not reject a non-array album response outright', async () => {
|
||||
withCredentialsAndAlbums([ALBUM_ID]);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
if (url.includes('/albums')) return { ok: true, json: async () => ({}) } as Response;
|
||||
return { ok: true, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
const body = await (await GET(req())).json();
|
||||
const albumFinding = body.findings.find((f: { id: string }) => f.id === 'album-ids');
|
||||
|
||||
// A non-array body is treated the same as an empty list, not left uncast
|
||||
// and unchecked.
|
||||
expect(albumFinding?.level).toBe('error');
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Two albums that slugify to the same URL leave the second unreachable and
|
||||
* its settings resolved from the first (#632).
|
||||
*/
|
||||
describe('GET /api/admin/doctor — album slug collisions', () => {
|
||||
const knownAlbums = [
|
||||
{ id: 'a', albumName: 'Japan Trip' },
|
||||
{ id: 'b', albumName: 'Japan-Trip' },
|
||||
];
|
||||
|
||||
function withCredentialsAndAlbums(config: Partial<typeof baseConfig>) {
|
||||
getConfigMock.mockReturnValueOnce({
|
||||
...baseConfig,
|
||||
needsCredentials: false,
|
||||
immich: { apiUrl: 'https://immich.example', apiKey: 'key' },
|
||||
...config,
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
if (url.includes('/albums')) return { ok: true, json: async () => knownAlbums } as Response;
|
||||
return { ok: true, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it('flags two standalone albums whose Immich names slugify alike', async () => {
|
||||
withCredentialsAndAlbums({ albums: ['a', 'b'], standaloneAlbums: ['a', 'b'] });
|
||||
|
||||
const body = await (await GET(req())).json();
|
||||
const finding = body.findings.find((f: { id: string }) => f.id === 'album-slugs');
|
||||
|
||||
expect(finding?.level).toBe('error');
|
||||
expect(finding?.detail).toContain('Japan Trip');
|
||||
expect(finding?.detail).toContain('Japan-Trip');
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('is quiet when the same names live in different subpages', async () => {
|
||||
withCredentialsAndAlbums({
|
||||
albums: ['a', 'b'],
|
||||
standaloneAlbums: [],
|
||||
subpages: [
|
||||
{ name: 'Trips A', albumIds: ['a'] },
|
||||
{ name: 'Trips B', albumIds: ['b'] },
|
||||
],
|
||||
});
|
||||
|
||||
const body = await (await GET(req())).json();
|
||||
const finding = body.findings.find((f: { id: string }) => f.id === 'album-slugs');
|
||||
|
||||
expect(finding?.level).toBe('ok');
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
77
app/api/admin/__tests__/settings-write.test.ts
Normal file
77
app/api/admin/__tests__/settings-write.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user