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:
@@ -54,6 +54,19 @@ function forgeToken(signingKey: Buffer, payload: object): string {
|
||||
return `${data}.${sig}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The production derivation (lib/admin/auth.ts's getSigningKey()), so a
|
||||
* forged token is rejected only for the reason each test names — the `exp`
|
||||
* or `role` check — rather than failing the HMAC compare first with a stale
|
||||
* key. SHA-256 of `admin:${secret}:${password}` was that key before the
|
||||
* session key moved to scrypt; verifyAdminToken has rejected it ever since,
|
||||
* so a token forged that way never reached the checks below the signature
|
||||
* compare at all (#637).
|
||||
*/
|
||||
function signingKey(secret: string, password: string): Buffer {
|
||||
return crypto.scryptSync(password, `folio-admin-session:${secret}`, 32);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.__TEST_AUTH_SECRET = AUTH_SECRET;
|
||||
process.env.__TEST_ADMIN_PASSWORD = ADMIN_PASSWORD;
|
||||
@@ -135,20 +148,14 @@ describe('admin session tokens', () => {
|
||||
|
||||
it('rejects an expired token', async () => {
|
||||
const { verifyAdminToken } = await loadAuth();
|
||||
const key = crypto
|
||||
.createHash('sha256')
|
||||
.update(`admin:${AUTH_SECRET}:${ADMIN_PASSWORD}`)
|
||||
.digest();
|
||||
const key = signingKey(AUTH_SECRET, ADMIN_PASSWORD);
|
||||
const expired = forgeToken(key, { role: 'admin', iat: 0, exp: Date.now() - 1000 });
|
||||
expect(verifyAdminToken(expired)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a validly signed token whose role is not admin', async () => {
|
||||
const { verifyAdminToken } = await loadAuth();
|
||||
const key = crypto
|
||||
.createHash('sha256')
|
||||
.update(`admin:${AUTH_SECRET}:${ADMIN_PASSWORD}`)
|
||||
.digest();
|
||||
const key = signingKey(AUTH_SECRET, ADMIN_PASSWORD);
|
||||
const wrongRole = forgeToken(key, { role: 'viewer', iat: 0, exp: Date.now() + 60_000 });
|
||||
expect(verifyAdminToken(wrongRole)).toBe(false);
|
||||
});
|
||||
|
||||
120
lib/__tests__/atomicWrite.test.ts
Normal file
120
lib/__tests__/atomicWrite.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { mkdtemp, readdir, readFile, rm, writeFile, mkdir, stat } from 'fs/promises';
|
||||
import { atomicWrite } from '../atomicWrite';
|
||||
|
||||
/**
|
||||
* This is the primitive every piece of user-supplied content is written
|
||||
* through — gallery.yaml, settings.yaml, journal entries, about.md, the
|
||||
* favicon. It existed as six hand-written copies that had already drifted in
|
||||
* their temp-file naming and their encoding, so it is worth pinning properly.
|
||||
*/
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(os.tmpdir(), 'folio-atomic-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const leftovers = async () => (await readdir(root)).filter((f) => f.endsWith('.tmp'));
|
||||
|
||||
describe('atomicWrite', () => {
|
||||
it('writes a new file and leaves no temp file behind', async () => {
|
||||
const target = path.join(root, 'settings.yaml');
|
||||
|
||||
await atomicWrite(target, 'title: Folio\n');
|
||||
|
||||
expect(await readFile(target, 'utf8')).toBe('title: Folio\n');
|
||||
expect(await leftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('replaces an existing file', async () => {
|
||||
const target = path.join(root, 'gallery.yaml');
|
||||
await writeFile(target, 'old');
|
||||
|
||||
await atomicWrite(target, 'new');
|
||||
|
||||
expect(await readFile(target, 'utf8')).toBe('new');
|
||||
});
|
||||
|
||||
it('writes a Buffer verbatim', async () => {
|
||||
const target = path.join(root, 'favicon.ico');
|
||||
// A byte that is not valid UTF-8, so an accidental string conversion shows.
|
||||
const bytes = Buffer.from([0x00, 0x01, 0xff, 0xfe]);
|
||||
|
||||
await atomicWrite(target, bytes);
|
||||
|
||||
expect(Buffer.compare(await readFile(target), bytes)).toBe(0);
|
||||
});
|
||||
|
||||
it('round-trips non-ASCII content as utf8', async () => {
|
||||
const target = path.join(root, 'about.md');
|
||||
const content = '# Über mich\n\nFotografie aus Brandenburg — 日本語 too.\n';
|
||||
|
||||
await atomicWrite(target, content);
|
||||
|
||||
expect(await readFile(target, 'utf8')).toBe(content);
|
||||
});
|
||||
|
||||
it('gives concurrent writes to the same path distinct temp files', async () => {
|
||||
const target = path.join(root, 'contended.yaml');
|
||||
|
||||
// The failure this guards against is a shared temp name: one writer's
|
||||
// bytes published under the other's, or a truncated mix of both.
|
||||
await Promise.all(Array.from({ length: 25 }, (_, i) => atomicWrite(target, `writer-${i}\n`)));
|
||||
|
||||
const survivor = await readFile(target, 'utf8');
|
||||
expect(survivor).toMatch(/^writer-\d+\n$/);
|
||||
expect(await leftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('cleans up the temp file and rethrows when the write cannot land', async () => {
|
||||
// A directory at the target path makes rename fail after the temp file was
|
||||
// written successfully — the exact window the cleanup exists for.
|
||||
const target = path.join(root, 'occupied');
|
||||
await mkdir(target);
|
||||
|
||||
await expect(atomicWrite(target, 'content')).rejects.toThrow();
|
||||
expect(await leftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects when the directory does not exist, without creating it', async () => {
|
||||
const target = path.join(root, 'missing-dir', 'file.yaml');
|
||||
|
||||
await expect(atomicWrite(target, 'content')).rejects.toThrow();
|
||||
expect(await readdir(root)).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A secret file (install.json's API key and auth secret) used to be
|
||||
* written with the default mode and `chmod`ed to 0600 afterwards — briefly
|
||||
* world-readable between the two. `mode` applies to the temp file before
|
||||
* the rename, so the file is never created with the wrong permissions at
|
||||
* all (GHSA-w293-x8pc-j4cv). Skipped on Windows, where POSIX mode bits do
|
||||
* not apply.
|
||||
*/
|
||||
const describeMode = process.platform === 'win32' ? describe.skip : describe;
|
||||
describeMode('mode option', () => {
|
||||
it('creates a new file with the given mode', async () => {
|
||||
const target = path.join(root, 'install.json');
|
||||
|
||||
await atomicWrite(target, '{}', { mode: 0o600 });
|
||||
|
||||
const mode = (await stat(target)).mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
});
|
||||
|
||||
it('defaults to the process umask when no mode is given', async () => {
|
||||
const target = path.join(root, 'gallery.yaml');
|
||||
|
||||
await atomicWrite(target, 'albums: []\n');
|
||||
|
||||
const mode = (await stat(target)).mode & 0o777;
|
||||
expect(mode & 0o600).toBe(0o600); // owner read/write, at minimum
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
authenticate,
|
||||
isAuthenticated,
|
||||
isAlbumReachable,
|
||||
withoutLockedAlbums,
|
||||
findSubpageBySlug,
|
||||
} from '@/lib/auth';
|
||||
|
||||
@@ -258,3 +259,30 @@ describe('isAlbumReachable', () => {
|
||||
expect(isAlbumReachable(OWN_PASSWORD, (n) => subpage(n) ?? album(n))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The essay layout puts the photographs of several albums on one page and
|
||||
* never passes through an album's own gate, so it has to leave out the ones
|
||||
* that are still locked.
|
||||
*/
|
||||
describe('withoutLockedAlbums', () => {
|
||||
const OPEN = { id: '00000000-0000-0000-0000-000000000002' };
|
||||
const LOCKED = { id: '00000000-0000-0000-0000-000000000005' };
|
||||
|
||||
it('keeps an album that has no password of its own', () => {
|
||||
expect(withoutLockedAlbums([OPEN], () => undefined)).toEqual([OPEN]);
|
||||
});
|
||||
|
||||
it('leaves out an album whose password has not been given', () => {
|
||||
expect(withoutLockedAlbums([OPEN, LOCKED], () => undefined)).toEqual([OPEN]);
|
||||
});
|
||||
|
||||
it('brings it back once it has been unlocked, in the original order', async () => {
|
||||
const header = (await authenticate(LOCKED.id, 'albumpass', 'album'))!;
|
||||
const [name, rest] = header.split('=');
|
||||
const token = rest.split(';')[0];
|
||||
const getCookie = (asked: string) => (asked === name ? token : undefined);
|
||||
|
||||
expect(withoutLockedAlbums([LOCKED, OPEN], getCookie)).toEqual([LOCKED, OPEN]);
|
||||
});
|
||||
});
|
||||
|
||||
111
lib/__tests__/backup-prune.test.ts
Normal file
111
lib/__tests__/backup-prune.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { mkdtemp, mkdir, readdir, rm, writeFile } from 'fs/promises';
|
||||
|
||||
/**
|
||||
* Pre-restore snapshots were exempt from pruning while still being listed, so
|
||||
* every restore added a row that was never removed. After a dozen restores the
|
||||
* backup modal showed nothing but pre-restore entries and the ordinary saves
|
||||
* had dropped off the end.
|
||||
*
|
||||
* They are still counted separately from saves — ten saves must not push out
|
||||
* the one snapshot that undoes a restore — but each kind is now capped.
|
||||
*
|
||||
* The service resolves content/ from process.cwd() at import time, so each test
|
||||
* points cwd at a fresh temp directory and re-imports it.
|
||||
*/
|
||||
let root: string;
|
||||
let contentDir: string;
|
||||
let backupDir: string;
|
||||
let service: typeof import('../admin/yaml-service');
|
||||
|
||||
const stamp = (n: number) => `2026-05-${String(n).padStart(2, '0')}T12-00-00-000Z`;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(os.tmpdir(), 'folio-prune-'));
|
||||
contentDir = path.join(root, 'content');
|
||||
backupDir = path.join(contentDir, '.backups');
|
||||
await mkdir(backupDir, { recursive: true });
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(root);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.resetModules();
|
||||
service = await import('../admin/yaml-service');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const backups = async (kind?: 'pre-restore') => {
|
||||
const files = (await readdir(backupDir)).filter((f) => f.startsWith('settings.yaml'));
|
||||
return kind
|
||||
? files.filter((f) => f.includes(kind))
|
||||
: files.filter((f) => !f.includes('pre-restore'));
|
||||
};
|
||||
|
||||
describe('backup pruning', () => {
|
||||
it('caps pre-restore snapshots instead of letting them accumulate', async () => {
|
||||
await writeFile(path.join(contentDir, 'settings.yaml'), 'title: Folio\n');
|
||||
for (let i = 1; i <= 14; i++) {
|
||||
await writeFile(path.join(backupDir, `settings.yaml.${stamp(i)}.pre-restore.bak`), 'old');
|
||||
}
|
||||
|
||||
await service.writeSettingsYaml({ title: 'Folio' });
|
||||
|
||||
expect(await backups('pre-restore')).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('keeps the newest pre-restore snapshots, not the oldest', async () => {
|
||||
await writeFile(path.join(contentDir, 'settings.yaml'), 'title: Folio\n');
|
||||
for (let i = 1; i <= 14; i++) {
|
||||
await writeFile(path.join(backupDir, `settings.yaml.${stamp(i)}.pre-restore.bak`), 'old');
|
||||
}
|
||||
|
||||
await service.writeSettingsYaml({ title: 'Folio' });
|
||||
|
||||
const kept = await backups('pre-restore');
|
||||
expect(kept).toContain(`settings.yaml.${stamp(14)}.pre-restore.bak`);
|
||||
expect(kept).not.toContain(`settings.yaml.${stamp(1)}.pre-restore.bak`);
|
||||
});
|
||||
|
||||
it('does not let saves push out the snapshot that undoes a restore', async () => {
|
||||
await writeFile(path.join(contentDir, 'settings.yaml'), 'title: Folio\n');
|
||||
await writeFile(path.join(backupDir, `settings.yaml.${stamp(1)}.pre-restore.bak`), 'the undo');
|
||||
for (let i = 2; i <= 13; i++) {
|
||||
await writeFile(path.join(backupDir, `settings.yaml.${stamp(i)}.bak`), 'a save');
|
||||
}
|
||||
|
||||
await service.writeSettingsYaml({ title: 'Folio' });
|
||||
|
||||
// Saves are capped, but the lone pre-restore snapshot survives them.
|
||||
expect(await backups()).toHaveLength(10);
|
||||
expect(await backups('pre-restore')).toEqual([`settings.yaml.${stamp(1)}.pre-restore.bak`]);
|
||||
});
|
||||
|
||||
it('prunes on restore, without waiting for the next save', async () => {
|
||||
await writeFile(path.join(contentDir, 'settings.yaml'), 'title: current\n');
|
||||
await writeFile(path.join(backupDir, `settings.yaml.${stamp(1)}.bak`), 'title: old\n');
|
||||
for (let i = 2; i <= 13; i++) {
|
||||
await writeFile(path.join(backupDir, `settings.yaml.${stamp(i)}.pre-restore.bak`), 'old');
|
||||
}
|
||||
|
||||
await service.restoreBackup(`settings.yaml.${stamp(1)}.bak`);
|
||||
|
||||
// The restore adds one of its own, and the cap holds all the same.
|
||||
expect(await backups('pre-restore')).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('leaves the other file backups alone', async () => {
|
||||
await writeFile(path.join(contentDir, 'settings.yaml'), 'title: Folio\n');
|
||||
for (let i = 1; i <= 14; i++) {
|
||||
await writeFile(path.join(backupDir, `gallery.yaml.${stamp(i)}.pre-restore.bak`), 'old');
|
||||
}
|
||||
|
||||
await service.writeSettingsYaml({ title: 'Folio' });
|
||||
|
||||
const gallery = (await readdir(backupDir)).filter((f) => f.startsWith('gallery.yaml'));
|
||||
expect(gallery).toHaveLength(14);
|
||||
});
|
||||
});
|
||||
@@ -61,17 +61,22 @@ describe('restoreBackup rejects anything that is not a backup it produced', () =
|
||||
describe('restoreBackup accepts the names it writes', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
// The restore itself goes through atomicWrite (writeFile the temp file,
|
||||
// then rename it onto the target) rather than fs.copyFile, so a failure
|
||||
// partway through cannot truncate the live file (#630). fs.copyFile is
|
||||
// still used, but only for the pre-restore safety snapshot.
|
||||
it('restores a normal backup', async () => {
|
||||
await expect(
|
||||
restoreBackup('gallery.yaml.2026-05-31T17-30-00-000Z.bak'),
|
||||
).resolves.toBeUndefined();
|
||||
expect(fs.copyFile).toHaveBeenCalled();
|
||||
expect(fs.copyFile).toHaveBeenCalled(); // pre-restore snapshot
|
||||
expect(fs.rename).toHaveBeenCalled(); // atomic restore write
|
||||
});
|
||||
|
||||
it('restores an about.md backup onto content/about.md', async () => {
|
||||
await restoreBackup('about.md.2026-05-31T17-30-00-000Z.bak');
|
||||
|
||||
const calls = vi.mocked(fs.copyFile).mock.calls;
|
||||
const calls = vi.mocked(fs.rename).mock.calls;
|
||||
expect(String(calls[calls.length - 1][1])).toMatch(/content\/about\.md$/);
|
||||
});
|
||||
|
||||
@@ -84,8 +89,38 @@ describe('restoreBackup accepts the names it writes', () => {
|
||||
it('derives the destination from the matched name, not a substring search', async () => {
|
||||
await restoreBackup('settings.yaml.2026-05-31T17-30-00-000Z.bak');
|
||||
|
||||
// Last copyFile is backup → target; the first is the pre-restore snapshot.
|
||||
const calls = vi.mocked(fs.copyFile).mock.calls;
|
||||
const calls = vi.mocked(fs.rename).mock.calls;
|
||||
expect(String(calls[calls.length - 1][1])).toMatch(/content\/settings\.yaml$/);
|
||||
});
|
||||
|
||||
it('reads the backup before touching the pre-restore snapshot or the live file', async () => {
|
||||
await restoreBackup('gallery.yaml.2026-05-31T17-30-00-000Z.bak');
|
||||
|
||||
const readOrder = vi.mocked(fs.readFile).mock.invocationCallOrder[0];
|
||||
const copyOrder = vi.mocked(fs.copyFile).mock.invocationCallOrder[0];
|
||||
expect(readOrder).toBeLessThan(copyOrder);
|
||||
});
|
||||
|
||||
it('aborts without touching the live file when the safety snapshot fails for a reason other than ENOENT', async () => {
|
||||
vi.mocked(fs.copyFile).mockRejectedValueOnce(
|
||||
Object.assign(new Error('EACCES'), { code: 'EACCES' }),
|
||||
);
|
||||
|
||||
await expect(restoreBackup('gallery.yaml.2026-05-31T17-30-00-000Z.bak')).rejects.toThrow(
|
||||
'EACCES',
|
||||
);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(fs.rename).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('proceeds when the safety snapshot fails only because there is no current file yet', async () => {
|
||||
vi.mocked(fs.copyFile).mockRejectedValueOnce(
|
||||
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
restoreBackup('gallery.yaml.2026-05-31T17-30-00-000Z.bak'),
|
||||
).resolves.toBeUndefined();
|
||||
expect(fs.rename).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock('@/lib/env', () => ({
|
||||
CACHE_TTL: 300,
|
||||
RATE_LIMIT_RPM: 120,
|
||||
},
|
||||
normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/secret', () => ({
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('@/lib/env', () => ({
|
||||
RATE_LIMIT_RPM: 120,
|
||||
TRUSTED_PROXY_HOPS: 0,
|
||||
},
|
||||
normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/secret', () => ({
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('@/lib/env', () => ({
|
||||
RATE_LIMIT_RPM: 120,
|
||||
TRUSTED_PROXY_HOPS: 0,
|
||||
},
|
||||
normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/secret', () => ({
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock('@/lib/env', () => ({
|
||||
import {
|
||||
slugify,
|
||||
albumSlug,
|
||||
findAlbumSlugCollisions,
|
||||
normalizeSlug,
|
||||
buildSubpageGrid,
|
||||
sanitizeNavLinks,
|
||||
@@ -125,6 +126,55 @@ describe('albumSlug', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAlbumSlugCollisions', () => {
|
||||
it('groups albums whose names slugify alike', () => {
|
||||
const collisions = findAlbumSlugCollisions([
|
||||
{ id: 'a', name: 'Japan Trip' },
|
||||
{ id: 'b', name: 'Japan-Trip' },
|
||||
{ id: 'c', name: 'Iceland' },
|
||||
]);
|
||||
expect(collisions).toEqual([
|
||||
{
|
||||
slug: 'japan-trip',
|
||||
albums: [
|
||||
{ id: 'a', name: 'Japan Trip' },
|
||||
{ id: 'b', name: 'Japan-Trip' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports nothing when every slug is distinct', () => {
|
||||
expect(
|
||||
findAlbumSlugCollisions([
|
||||
{ id: 'a', name: 'Japan Trip' },
|
||||
{ id: 'b', name: 'Iceland' },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
// albumSlug() falls back to the id for these, which is unique by
|
||||
// construction — two emoji-only albums do not actually collide.
|
||||
it('does not flag two names that both slugify to nothing', () => {
|
||||
expect(
|
||||
findAlbumSlugCollisions([
|
||||
{ id: 'a', name: '🎉' },
|
||||
{ id: 'b', name: '🎊' },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('groups three or more colliding names together', () => {
|
||||
const collisions = findAlbumSlugCollisions([
|
||||
{ id: 'a', name: 'Trip' },
|
||||
{ id: 'b', name: 'TRIP' },
|
||||
{ id: 'c', name: 'trip' },
|
||||
]);
|
||||
expect(collisions).toHaveLength(1);
|
||||
expect(collisions[0].albums).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeSlug', () => {
|
||||
// Next hands catch-all route segments over percent-encoded, so this is the
|
||||
// half of #522 that fixing slugify() alone did not cure.
|
||||
|
||||
@@ -252,3 +252,73 @@ describe('deriveGallery accepts valid structures', () => {
|
||||
expect(result.subpages[1].enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `slugify` folds diacritics and punctuation, so distinct names collide
|
||||
* easily — "Portfolio 2024" and "Portfolio-2024" both become
|
||||
* "portfolio-2024". Every consumer that resolves a slug takes the first
|
||||
* match, so the second entry was silently unreachable and its settings
|
||||
* resolved from the first (#632).
|
||||
*/
|
||||
describe('deriveGallery rejects colliding slugs', () => {
|
||||
it('rejects two subpages that slugify alike, naming both', () => {
|
||||
expect(() =>
|
||||
deriveGallery({
|
||||
subpages: [
|
||||
{ name: 'Portfolio 2024', albums: [A] },
|
||||
{ name: 'Portfolio-2024', albums: [B] },
|
||||
],
|
||||
} as unknown as GalleryYaml),
|
||||
).toThrow(/"Portfolio 2024".*"Portfolio-2024"|"Portfolio-2024".*"Portfolio 2024"/);
|
||||
});
|
||||
|
||||
it('accepts subpages whose slugs are genuinely distinct', () => {
|
||||
expect(() =>
|
||||
deriveGallery({
|
||||
subpages: [
|
||||
{ name: 'Portfolio 2024', albums: [A] },
|
||||
{ name: 'Portfolio 2025', albums: [B] },
|
||||
],
|
||||
} as unknown as GalleryYaml),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects two standalone albums whose title overrides slugify alike', () => {
|
||||
expect(() =>
|
||||
deriveGallery({
|
||||
albums: [{ [A]: { title: 'Japan Trip' } }, { [B]: { title: 'Japan-Trip' } }],
|
||||
} as unknown as GalleryYaml),
|
||||
).toThrow(/"Japan Trip".*"Japan-Trip"|"Japan-Trip".*"Japan Trip"/);
|
||||
});
|
||||
|
||||
it('rejects two album overrides within the same subpage that slugify alike', () => {
|
||||
expect(() =>
|
||||
deriveGallery({
|
||||
subpages: [
|
||||
{
|
||||
name: 'Trips',
|
||||
albums: [{ [A]: { title: 'Japan Trip' } }, { [B]: { title: 'Japan-Trip' } }],
|
||||
},
|
||||
],
|
||||
} as unknown as GalleryYaml),
|
||||
).toThrow(/subpage "Trips"/);
|
||||
});
|
||||
|
||||
it('does not flag the same collision across two different subpages', () => {
|
||||
// Different URL prefixes — /trips-a/japan-trip and /trips-b/japan-trip do
|
||||
// not collide with each other, only within one subpage's own route.
|
||||
expect(() =>
|
||||
deriveGallery({
|
||||
subpages: [
|
||||
{ name: 'Trips A', albums: [{ [A]: { title: 'Japan Trip' } }] },
|
||||
{ name: 'Trips B', albums: [{ [B]: { title: 'Japan Trip' } }] },
|
||||
],
|
||||
} as unknown as GalleryYaml),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not flag an album with no title override against another with no override', () => {
|
||||
// Neither album's real name is known without Immich — left to the doctor.
|
||||
expect(() => deriveGallery({ albums: [A, B] } as unknown as GalleryYaml)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
checkProxyHops,
|
||||
countForwardedHops,
|
||||
checkAlbumIds,
|
||||
checkAlbumSlugCollisions,
|
||||
checkAlbumsShared,
|
||||
checkPasswords,
|
||||
checkWritable,
|
||||
@@ -11,6 +12,14 @@ import {
|
||||
worstLevel,
|
||||
} from '../admin/doctor';
|
||||
|
||||
/** A stand-in for lib/config/schema.ts's slugify(), good enough to test the
|
||||
* grouping logic without doctor.ts importing anything (see its own comment). */
|
||||
const testSlug = (name: string) =>
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
describe('checkAuthSecret', () => {
|
||||
it('accepts a long secret', () => {
|
||||
expect(checkAuthSecret('a'.repeat(64)).level).toBe('ok');
|
||||
@@ -94,6 +103,19 @@ describe('checkProxyHops', () => {
|
||||
it('accepts one configured hop against one corroborated entry', () => {
|
||||
expect(checkProxyHops(1, 1, true).level).toBe('ok');
|
||||
});
|
||||
|
||||
/**
|
||||
* Cloudflare (1 hop) in front of nginx (1 more, 2 total) with the value set
|
||||
* to 1 used to report "matches the observed chain" — the client IP was
|
||||
* actually read from the Cloudflare edge address, so every visitor behind
|
||||
* that PoP shared one rate-limit bucket (#629).
|
||||
*/
|
||||
it('warns, not passes, when fewer hops are configured than arrived', () => {
|
||||
const f = checkProxyHops(1, 2, true);
|
||||
expect(f.level).toBe('warn');
|
||||
expect(f.title).toContain('1');
|
||||
expect(f.title).toContain('2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAlbumIds', () => {
|
||||
@@ -142,6 +164,71 @@ describe('checkAlbumsShared', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAlbumSlugCollisions', () => {
|
||||
const known = [
|
||||
{ id: 'a', albumName: 'Japan Trip' },
|
||||
{ id: 'b', albumName: 'Japan-Trip' },
|
||||
{ id: 'c', albumName: 'Iceland' },
|
||||
];
|
||||
|
||||
it('is quiet when nothing collides', () => {
|
||||
const f = checkAlbumSlugCollisions(
|
||||
[{ context: 'gallery.yaml albums', albumIds: ['a', 'c'] }],
|
||||
{},
|
||||
known,
|
||||
testSlug,
|
||||
);
|
||||
expect(f.level).toBe('ok');
|
||||
});
|
||||
|
||||
it('flags two albums in the same group whose Immich names slugify alike', () => {
|
||||
const f = checkAlbumSlugCollisions(
|
||||
[{ context: 'gallery.yaml albums', albumIds: ['a', 'b'] }],
|
||||
{},
|
||||
known,
|
||||
testSlug,
|
||||
);
|
||||
expect(f.level).toBe('error');
|
||||
expect(f.detail).toContain('Japan Trip');
|
||||
expect(f.detail).toContain('Japan-Trip');
|
||||
expect(f.albumIds).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('does not flag the same collision across two different groups', () => {
|
||||
// /trips-a/japan-trip and /trips-b/japan-trip are different URLs.
|
||||
const f = checkAlbumSlugCollisions(
|
||||
[
|
||||
{ context: 'subpage "Trips A"', albumIds: ['a'] },
|
||||
{ context: 'subpage "Trips B"', albumIds: ['b'] },
|
||||
],
|
||||
{},
|
||||
known,
|
||||
testSlug,
|
||||
);
|
||||
expect(f.level).toBe('ok');
|
||||
});
|
||||
|
||||
it('prefers a title override over the Immich name', () => {
|
||||
const f = checkAlbumSlugCollisions(
|
||||
[{ context: 'gallery.yaml albums', albumIds: ['a', 'c'] }],
|
||||
{ c: 'Japan Trip' }, // overrides "Iceland" — now collides with "a"
|
||||
known,
|
||||
testSlug,
|
||||
);
|
||||
expect(f.level).toBe('error');
|
||||
});
|
||||
|
||||
it('says nothing about an id Immich does not know and has no override', () => {
|
||||
const f = checkAlbumSlugCollisions(
|
||||
[{ context: 'gallery.yaml albums', albumIds: ['a', 'ghost'] }],
|
||||
{},
|
||||
known,
|
||||
testSlug,
|
||||
);
|
||||
expect(f.level).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPasswords', () => {
|
||||
it('passes when everything is hashed', () => {
|
||||
expect(checkPasswords([{ label: 'Site', value: 'scrypt:aa:bb' }]).level).toBe('ok');
|
||||
|
||||
116
lib/__tests__/grid-columns-clamp.test.ts
Normal file
116
lib/__tests__/grid-columns-clamp.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/env', () => ({
|
||||
env: {
|
||||
IMMICH_API_URL: 'http://localhost:2283',
|
||||
IMMICH_API_KEY: 'test-key',
|
||||
SITE_TITLE: 'Test Gallery',
|
||||
SITE_SUBTITLE: '',
|
||||
CACHE_TTL: 300,
|
||||
IMMICH_TIMEOUT_MS: 15000,
|
||||
RATE_LIMIT_RPM: 120,
|
||||
TRUSTED_PROXY_HOPS: 0,
|
||||
},
|
||||
normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/secret', () => ({
|
||||
resolveAuthSecret: () => 'test-auth-secret-32-chars-long-min',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/config/parser', () => ({
|
||||
loadYaml: vi.fn(),
|
||||
clearYamlCache: vi.fn(),
|
||||
validateUuid: (id: string) => id,
|
||||
}));
|
||||
|
||||
import { getConfig, invalidateConfigCache, deriveGallery } from '@/lib/config';
|
||||
import { loadYaml } from '@/lib/config/parser';
|
||||
import type { GalleryYaml } from '@/lib/config/schema';
|
||||
|
||||
const ALBUM_ID = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
function withSettings(settings: unknown) {
|
||||
vi.mocked(loadYaml).mockImplementation((filename: string) => {
|
||||
if (filename === 'gallery.yaml') return { albums: [ALBUM_ID] };
|
||||
if (filename === 'settings.yaml') return settings;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
invalidateConfigCache();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* `--grid-columns` is emitted unclamped in app/[...path]/page.tsx, so a bad
|
||||
* `columns` reaches `repeat()` invalid and collapses the whole grid to one
|
||||
* column with no error anywhere (#633). The cover grid already clamped
|
||||
* (buildCoverGridVars); the site-wide photo grid did not.
|
||||
*/
|
||||
describe('site-wide grid.columns / grid.gap clamping (#633)', () => {
|
||||
it('clamps a negative columns count to the minimum', () => {
|
||||
withSettings({ grid: { columns: -1 } });
|
||||
expect(getConfig().grid.columns).toBe(1);
|
||||
});
|
||||
|
||||
it('clamps an out-of-range columns count to the maximum', () => {
|
||||
withSettings({ grid: { columns: 99 } });
|
||||
expect(getConfig().grid.columns).toBe(6);
|
||||
});
|
||||
|
||||
it('falls back to the default when columns is not a number', () => {
|
||||
withSettings({ grid: { columns: 'three' as unknown as number } });
|
||||
expect(getConfig().grid.columns).toBe(1); // clamp()'s min, not silently NaN
|
||||
});
|
||||
|
||||
it('keeps a legitimate columns value', () => {
|
||||
withSettings({ grid: { columns: 4 } });
|
||||
expect(getConfig().grid.columns).toBe(4);
|
||||
});
|
||||
|
||||
it('defaults columns to 3 when unset', () => {
|
||||
withSettings({});
|
||||
expect(getConfig().grid.columns).toBe(3);
|
||||
});
|
||||
|
||||
it('clamps gap the same way', () => {
|
||||
withSettings({ grid: { gap: -5 } });
|
||||
expect(getConfig().grid.gap).toBe(0);
|
||||
withSettings({ grid: { gap: 9999 } });
|
||||
invalidateConfigCache();
|
||||
expect(getConfig().grid.gap).toBe(48);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Same guard for a subpage's or an album's own grid override — the merged
|
||||
* value (site-wide default overridden by this) is what actually reaches
|
||||
* `--grid-columns`.
|
||||
*/
|
||||
describe('per-subpage grid override clamping (#633)', () => {
|
||||
it('clamps a negative subpage columns override', () => {
|
||||
const gallery = {
|
||||
subpages: [{ name: 'Trips', albums: [ALBUM_ID], grid: { columns: -1 } }],
|
||||
} as unknown as GalleryYaml;
|
||||
const derived = deriveGallery(gallery);
|
||||
expect(derived.subpages[0].grid?.columns).toBe(1);
|
||||
});
|
||||
|
||||
it('clamps an out-of-range subpage columns override', () => {
|
||||
const gallery = {
|
||||
subpages: [{ name: 'Trips', albums: [ALBUM_ID], grid: { columns: 40 } }],
|
||||
} as unknown as GalleryYaml;
|
||||
const derived = deriveGallery(gallery);
|
||||
expect(derived.subpages[0].grid?.columns).toBe(6);
|
||||
});
|
||||
|
||||
it('keeps a legitimate subpage columns override', () => {
|
||||
const gallery = {
|
||||
subpages: [{ name: 'Trips', albums: [ALBUM_ID], grid: { columns: 2 } }],
|
||||
} as unknown as GalleryYaml;
|
||||
const derived = deriveGallery(gallery);
|
||||
expect(derived.subpages[0].grid?.columns).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -15,14 +15,14 @@ describe('resolveImageSize', () => {
|
||||
expect(resolveImageSize(null, null)).toBe('preview');
|
||||
});
|
||||
|
||||
it('honours an explicit size on its own', () => {
|
||||
it('honours an explicit size on its own, up to the preview ceiling', () => {
|
||||
expect(resolveImageSize('thumbnail', null)).toBe('thumbnail');
|
||||
expect(resolveImageSize('original', null)).toBe('original');
|
||||
expect(resolveImageSize('preview', null)).toBe('preview');
|
||||
});
|
||||
|
||||
it('falls back to the width-derived tier when no size is given', () => {
|
||||
expect(resolveImageSize(null, '128')).toBe('thumbnail');
|
||||
expect(resolveImageSize(null, '3840')).toBe('original');
|
||||
expect(resolveImageSize(null, '1000')).toBe('preview');
|
||||
});
|
||||
|
||||
it('ignores an unrecognised size rather than trusting it', () => {
|
||||
@@ -57,4 +57,33 @@ describe('resolveImageSize', () => {
|
||||
expect(resolveImageSize('preview', '640')).toBe('preview');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `/api/image` treats the opaque token as the whole capability check —
|
||||
* holding one means you saw the page it was rendered on. That is right for
|
||||
* a preview, not for the un-downsampled original: only `/api/download`
|
||||
* verifies the album allowlist, `download: true` and every password gate.
|
||||
* `?size=original` or a large `?w=` alone used to reach `original`
|
||||
* unopposed, since the smaller()-of-both-parameters check only ran when
|
||||
* both were present (GHSA-36m4-p39x-9wx8).
|
||||
*/
|
||||
describe('never returns original, regardless of what is asked for', () => {
|
||||
it('caps an explicit ?size=original to preview', () => {
|
||||
expect(resolveImageSize('original', null)).toBe('preview');
|
||||
});
|
||||
|
||||
it('caps a large ?w= alone to preview', () => {
|
||||
expect(resolveImageSize(null, '3840')).toBe('preview');
|
||||
expect(resolveImageSize(null, '1920')).toBe('preview');
|
||||
});
|
||||
|
||||
it('caps the combination of size=original and a large width', () => {
|
||||
expect(resolveImageSize('original', '3840')).toBe('preview');
|
||||
});
|
||||
|
||||
it('still narrows to thumbnail when the width says so', () => {
|
||||
// The cap is a ceiling, not a floor — a small width may still win.
|
||||
expect(resolveImageSize('original', '128')).toBe('thumbnail');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { immich, ImmichUnavailableError } from '../immich';
|
||||
import * as config from '../config';
|
||||
import { cache } from '../cache';
|
||||
@@ -8,6 +8,8 @@ import type { AlbumSortMode } from '../albumSort';
|
||||
// able to change it between calls without rebuilding the whole mock.
|
||||
const albumSortModes: Record<string, AlbumSortMode> = {};
|
||||
const albumManualOrders: Record<string, string[]> = {};
|
||||
// Same for the subpages: the route tests below add one and take it away again.
|
||||
const subpages: { slug: string; albumIds: string[]; enabled?: boolean }[] = [];
|
||||
|
||||
// Mock the config by wrapping it in a factory
|
||||
vi.mock('../config', async () => {
|
||||
@@ -18,8 +20,10 @@ vi.mock('../config', async () => {
|
||||
immich: { apiUrl: 'http://immich.test/api', apiKey: 'test-key' },
|
||||
authSecret: 'test-auth-secret-32-chars-long-min',
|
||||
albums: ['album-1', 'album-2'],
|
||||
standaloneAlbums: ['album-2', 'album-1'],
|
||||
subpages: [],
|
||||
standaloneAlbums: ['album-2', 'album-1'].filter(
|
||||
(id) => !subpages.some((sp) => sp.albumIds.includes(id)),
|
||||
),
|
||||
subpages,
|
||||
albumOverrides: { 'album-1': 'Override Name' },
|
||||
albumDescriptions: {},
|
||||
albumSortModes,
|
||||
@@ -267,6 +271,53 @@ describe('ImmichClient', () => {
|
||||
const result = await immich.streamVideo('asset-1', 'bytes=0-1023');
|
||||
expect(result?.status).toBe(206);
|
||||
});
|
||||
|
||||
/**
|
||||
* Neither error branch reads the body. Under undici an unconsumed one
|
||||
* keeps its socket out of the pool until GC finalises it, so a proxy
|
||||
* answering every request with an error accumulates orphaned sockets for
|
||||
* as long as the fault lasts (#635).
|
||||
*/
|
||||
const streamResWithBody = (status: number) => {
|
||||
const cancel = vi.fn();
|
||||
return {
|
||||
res: {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
body: { cancel },
|
||||
headers: { get: () => null },
|
||||
},
|
||||
cancel,
|
||||
};
|
||||
};
|
||||
|
||||
it('streamAsset cancels the body on the "gone" path', async () => {
|
||||
const { res, cancel } = streamResWithBody(404);
|
||||
mockFetch.mockResolvedValueOnce(res);
|
||||
await expect(immich.streamAsset('asset-1')).resolves.toBeNull();
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('streamAsset cancels the body on the "outage" path', async () => {
|
||||
const { res, cancel } = streamResWithBody(500);
|
||||
mockFetch.mockResolvedValueOnce(res);
|
||||
await expect(immich.streamAsset('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('streamVideo cancels the body on the "gone" path', async () => {
|
||||
const { res, cancel } = streamResWithBody(404);
|
||||
mockFetch.mockResolvedValueOnce(res);
|
||||
await expect(immich.streamVideo('asset-1')).resolves.toBeNull();
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('streamVideo cancels the body on the "outage" path', async () => {
|
||||
const { res, cancel } = streamResWithBody(502);
|
||||
mockFetch.mockResolvedValueOnce(res);
|
||||
await expect(immich.streamVideo('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
// Measured before the fix: 5 sequential lookups of a missing asset produced 5
|
||||
@@ -438,6 +489,66 @@ describe('ImmichClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* config.albums is the union of standalone and subpage albums. A slug has to
|
||||
* be looked up among the albums of the route it arrived on, or a top-level
|
||||
* URL answers for an album whose only route is a subpage — past that
|
||||
* subpage's password.
|
||||
*/
|
||||
describe('getAlbumBySlug() searches the route, not the allowlist', () => {
|
||||
beforeEach(() => {
|
||||
const json = (body: unknown) => ({
|
||||
ok: true,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => body,
|
||||
});
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes('/search/metadata')) {
|
||||
return json({ assets: { items: [], nextPage: null, total: 0 } });
|
||||
}
|
||||
const one = /\/albums\/(album-[12])/.exec(url);
|
||||
if (one) {
|
||||
const albumName = one[1] === 'album-1' ? 'Client Preview' : 'Open Work';
|
||||
return json({ id: one[1], albumName, assetCount: 0, assets: [], order: 'desc' });
|
||||
}
|
||||
return json([
|
||||
{ id: 'album-1', albumName: 'Client Preview', assetCount: 0 },
|
||||
{ id: 'album-2', albumName: 'Open Work', assetCount: 0 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
subpages.length = 0;
|
||||
});
|
||||
|
||||
it('finds a standalone album at the top level', async () => {
|
||||
const album = await immich.getAlbumBySlug('open-work');
|
||||
expect(album?.id).toBe('album-2');
|
||||
});
|
||||
|
||||
it('does not find a subpage album at the top level', async () => {
|
||||
subpages.push({ slug: 'handover', albumIds: ['album-1'] });
|
||||
|
||||
// album-1 carries the 'Override Name' override, so that is its slug.
|
||||
expect(await immich.getAlbumBySlug('override-name')).toBeNull();
|
||||
expect((await immich.getAlbumBySlug('override-name', 'handover'))?.id).toBe('album-1');
|
||||
});
|
||||
|
||||
it('does not find anything through a disabled subpage', async () => {
|
||||
subpages.push({ slug: 'handover', albumIds: ['album-1'], enabled: false });
|
||||
|
||||
expect(await immich.getAlbumBySlug('override-name', 'handover')).toBeNull();
|
||||
expect(await immich.getAlbumBySlug('override-name')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not find a standalone album through a subpage that does not list it', async () => {
|
||||
subpages.push({ slug: 'handover', albumIds: ['album-1'] });
|
||||
|
||||
expect(await immich.getAlbumBySlug('open-work', 'handover')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStandaloneAlbums()', () => {
|
||||
it('orders albums by gallery.yaml order, not Immich API order', async () => {
|
||||
// The Immich API returns albums in its own (creation/update) order;
|
||||
@@ -760,6 +871,62 @@ describe('stale fallback when Immich is unavailable', () => {
|
||||
await expect(immich.getAlbums()).rejects.toThrow(ImmichUnavailableError);
|
||||
});
|
||||
|
||||
/**
|
||||
* getAlbums(true) used to delete the cache entry before attempting the
|
||||
* fetch. A request with forceFresh that then failed left every later
|
||||
* visitor — not just the one who asked for a refresh — with nothing to
|
||||
* fall back to, for as long as the outage lasted (GHSA-w293-x8pc-j4cv).
|
||||
*/
|
||||
it('forceFresh does not delete the entry a failed refresh could have fallen back to', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => [
|
||||
{
|
||||
id: 'album-1',
|
||||
albumName: 'Original',
|
||||
description: '',
|
||||
albumThumbnailAssetId: null,
|
||||
assetCount: 1,
|
||||
assets: [],
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
order: 'desc',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(await immich.getAlbums()).toHaveLength(1);
|
||||
|
||||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const refreshed = await immich.getAlbums(true);
|
||||
|
||||
expect(refreshed).toHaveLength(1);
|
||||
expect(refreshed[0].id).toBe('album-1');
|
||||
});
|
||||
|
||||
it('getAlbum(id, true) has the same guarantee as getAlbums(true)', async () => {
|
||||
// GET /albums/:id and POST /search/metadata run concurrently (Promise.all
|
||||
// in loadAlbum), so they must be routed by URL rather than queued in call
|
||||
// order — same pattern as the getAlbum() describe block below.
|
||||
mockFetch.mockImplementation(async (url: string) => ({
|
||||
ok: true,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () =>
|
||||
url.includes('/search/metadata')
|
||||
? { assets: { items: [], nextPage: null, total: 0 } }
|
||||
: { id: 'album-1', albumName: 'Original', assetCount: 0, assets: [], order: 'desc' },
|
||||
}));
|
||||
expect(await immich.getAlbum('album-1')).not.toBeNull();
|
||||
|
||||
mockFetch.mockImplementation(async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
});
|
||||
const refreshed = await immich.getAlbum('album-1', true);
|
||||
|
||||
expect(refreshed).not.toBeNull();
|
||||
expect(refreshed?.id).toBe('album-1');
|
||||
});
|
||||
|
||||
it('does not swallow a definitive 404 as an outage', async () => {
|
||||
// A missing album must keep reporting missing, not fall back to stale data.
|
||||
mockFetch.mockResolvedValue({
|
||||
@@ -775,4 +942,44 @@ describe('stale fallback when Immich is unavailable', () => {
|
||||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
await expect(immich.getAlbum('not-allowed')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
// #626: a cached 404 used to come back from the stale fallback as the MISSING
|
||||
// sentinel itself — a truthy object with no fields. Every `if (!asset)` guard
|
||||
// passed on it and the page answered 500 for the length of the outage.
|
||||
const notFound = {
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
headers: { get: () => 'application/json' },
|
||||
};
|
||||
|
||||
it('answers a stale "missing" asset with null during an outage', async () => {
|
||||
mockFetch.mockResolvedValue(notFound);
|
||||
await expect(immich.getAssetInfo('deleted-hero')).resolves.toBeNull();
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.advanceTimersByTime(60_001); // past cacheTtl, inside the stale window
|
||||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
const result = await immich.getAssetInfo('deleted-hero');
|
||||
vi.useRealTimers();
|
||||
|
||||
// toBeNull, not toBeFalsy: the bug was a truthy object, so the precise
|
||||
// assertion is the one that would have failed.
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('answers a stale "missing" album with null during an outage', async () => {
|
||||
mockFetch.mockResolvedValue(notFound);
|
||||
await expect(immich.getAlbum('album-1')).resolves.toBeNull();
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.advanceTimersByTime(60_001);
|
||||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
const result = await immich.getAlbum('album-1');
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
107
lib/__tests__/immichCache.test.ts
Normal file
107
lib/__tests__/immichCache.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// vi.mock is hoisted above the file, so the stubs have to be too.
|
||||
const { getStale, set } = vi.hoisted(() => ({ getStale: vi.fn(), set: vi.fn() }));
|
||||
|
||||
vi.mock('../cache', () => ({ cache: { getStale, set } }));
|
||||
|
||||
import { MISSING, cacheSet, staleOrMissing, staleOrThrow } from '../immichCache';
|
||||
import { ImmichUnavailableError } from '../immichTransport';
|
||||
|
||||
/**
|
||||
* This policy decides what visitors see while Immich is down, and it used to be
|
||||
* reachable only through HTTP mocks against the client (#610). These are the
|
||||
* tests that became possible once it stood on its own.
|
||||
*/
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('staleOrThrow', () => {
|
||||
it('serves the last known good answer when Immich is unavailable', () => {
|
||||
getStale.mockReturnValue([{ id: 'album-1' }]);
|
||||
|
||||
const result = staleOrThrow('albums', new ImmichUnavailableError('502'), 'albums');
|
||||
|
||||
expect(result).toEqual([{ id: 'album-1' }]);
|
||||
});
|
||||
|
||||
it('rethrows when nothing usable is left in the cache', () => {
|
||||
// Past staleMaxAge the entry is gone, and the outage surfaces as before.
|
||||
getStale.mockReturnValue(null);
|
||||
|
||||
expect(() => staleOrThrow('albums', new ImmichUnavailableError('502'), 'albums')).toThrow(
|
||||
ImmichUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not serve stale data for an error that is not an outage', () => {
|
||||
// A TypeError means we asked the wrong question. Answering it with
|
||||
// yesterday's data would hide a real bug behind a plausible page.
|
||||
getStale.mockReturnValue([{ id: 'album-1' }]);
|
||||
|
||||
expect(() => staleOrThrow('albums', new TypeError('read of undefined'), 'albums')).toThrow(
|
||||
TypeError,
|
||||
);
|
||||
expect(getStale).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says so in the log when it serves something stale', () => {
|
||||
getStale.mockReturnValue(['cached']);
|
||||
|
||||
staleOrThrow('albums', new ImmichUnavailableError('502'), 'album list');
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('album list'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('the MISSING sentinel (#626)', () => {
|
||||
it('is never cast into real data by staleOrThrow', () => {
|
||||
// The sentinel handed back as an album is a truthy object with no fields;
|
||||
// every `if (!album)` guard passes and the page answers 500. For a key
|
||||
// that should only ever hold data, meeting it means nothing is usable.
|
||||
getStale.mockReturnValue(MISSING);
|
||||
|
||||
expect(() => staleOrThrow('albums', new ImmichUnavailableError('502'), 'albums')).toThrow(
|
||||
ImmichUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
it('comes back from staleOrMissing as null — the last authoritative answer', () => {
|
||||
getStale.mockReturnValue(MISSING);
|
||||
|
||||
expect(staleOrMissing('asset-x', new ImmichUnavailableError('502'), 'asset x')).toBeNull();
|
||||
});
|
||||
|
||||
it('lets staleOrMissing still serve real stale data', () => {
|
||||
getStale.mockReturnValue({ id: 'asset-x' });
|
||||
|
||||
expect(staleOrMissing('asset-x', new ImmichUnavailableError('502'), 'asset x')).toEqual({
|
||||
id: 'asset-x',
|
||||
});
|
||||
});
|
||||
|
||||
it('lets staleOrMissing throw when nothing is cached', () => {
|
||||
getStale.mockReturnValue(null);
|
||||
|
||||
expect(() => staleOrMissing('asset-x', new ImmichUnavailableError('502'), 'asset x')).toThrow(
|
||||
ImmichUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps staleOrMissing from answering a non-outage error', () => {
|
||||
getStale.mockReturnValue(MISSING);
|
||||
|
||||
expect(() => staleOrMissing('asset-x', new TypeError('bug'), 'asset x')).toThrow(TypeError);
|
||||
expect(getStale).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheSet', () => {
|
||||
it('passes the configured windows through', () => {
|
||||
cacheSet('albums', ['a'], 60_000, 3_600_000);
|
||||
|
||||
expect(set).toHaveBeenCalledWith('albums', ['a'], 60_000, 3_600_000);
|
||||
});
|
||||
});
|
||||
208
lib/__tests__/immichTransport.test.ts
Normal file
208
lib/__tests__/immichTransport.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { requestJson, ImmichUnavailableError, isTimeout } from '../immichTransport';
|
||||
|
||||
/**
|
||||
* The distinction these tests pin is the one that matters to visitors: `null`
|
||||
* means Immich answered and the thing is gone, an ImmichUnavailableError means
|
||||
* Immich could not answer at all. Conflating them once rendered every album URL
|
||||
* as a hard 404 during an outage — including albums that exist.
|
||||
*/
|
||||
|
||||
const opts = {
|
||||
apiUrl: 'http://immich.test/api',
|
||||
apiKey: 'test-key',
|
||||
timeoutMs: 5000,
|
||||
endpoint: '/albums',
|
||||
};
|
||||
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('requestJson', () => {
|
||||
it('returns the parsed body', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => json([{ id: 'album-1' }])),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).resolves.toEqual([{ id: 'album-1' }]);
|
||||
});
|
||||
|
||||
it('sends the API key and asks for JSON', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => json([]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await requestJson(opts);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('http://immich.test/api/albums');
|
||||
expect(init).toBeDefined();
|
||||
expect(init?.method).toBe('GET');
|
||||
expect(init?.headers).toMatchObject({ 'x-api-key': 'test-key', Accept: 'application/json' });
|
||||
});
|
||||
|
||||
it('posts when given a body', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => json([]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await requestJson({ ...opts, body: { albumId: 'a1' } });
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(init?.method).toBe('POST');
|
||||
expect(init?.body).toBe('{"albumId":"a1"}');
|
||||
});
|
||||
|
||||
it('returns null for a resource that is genuinely gone', async () => {
|
||||
for (const status of [404, 410]) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('', { status })),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).resolves.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws for a status that means "not right now"', async () => {
|
||||
// A 5xx, a rate limit or a rejected API key are outages, not deletions.
|
||||
for (const status of [401, 429, 500, 502, 503]) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('', { status, statusText: 'Nope' })),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).rejects.toBeInstanceOf(ImmichUnavailableError);
|
||||
}
|
||||
});
|
||||
|
||||
it('carries the status on the error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('', { status: 503, statusText: 'Unavailable' })),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
|
||||
/**
|
||||
* Neither the "gone" nor the "outage" branch reads the body. Under undici
|
||||
* an unconsumed one keeps its socket out of the pool until GC finalises it,
|
||||
* so a proxy answering every request with an error accumulates orphaned
|
||||
* sockets for as long as the fault lasts (#635).
|
||||
*/
|
||||
it('cancels the body on the "gone" path (404/410)', async () => {
|
||||
for (const status of [404, 410]) {
|
||||
const res = new Response('', { status });
|
||||
const cancelSpy = vi.spyOn(res.body!, 'cancel');
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => res),
|
||||
);
|
||||
|
||||
await requestJson(opts);
|
||||
|
||||
expect(cancelSpy).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
|
||||
it('cancels the body on the "outage" path (5xx)', async () => {
|
||||
const res = new Response('', { status: 503, statusText: 'Unavailable' });
|
||||
const cancelSpy = vi.spyOn(res.body!, 'cancel');
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => res),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).rejects.toBeInstanceOf(ImmichUnavailableError);
|
||||
expect(cancelSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('cancels the body when the content type is not JSON', async () => {
|
||||
const res = new Response('<html>502 Bad Gateway</html>', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
const cancelSpy = vi.spyOn(res.body!, 'cancel');
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => res),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).rejects.toThrow(/non-JSON/);
|
||||
expect(cancelSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('refuses a non-JSON body', async () => {
|
||||
// We always send Accept: application/json, so an HTML body is a gateway
|
||||
// error page, not an answer about the resource.
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response('<html>502 Bad Gateway</html>', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).rejects.toThrow(/non-JSON/);
|
||||
});
|
||||
|
||||
it('reports a timeout as such, with the budget in the message', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw Object.assign(new Error('aborted'), { name: 'TimeoutError' });
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).rejects.toThrow(/did not respond within 5000ms/);
|
||||
});
|
||||
|
||||
it('reports an unreachable host separately from a timeout', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).rejects.toThrow(/Cannot reach Immich/);
|
||||
});
|
||||
|
||||
it('turns a malformed body into an outage rather than a crash', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response('{ not json', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).rejects.toThrow(/malformed JSON/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTimeout', () => {
|
||||
it('recognises both ways of giving up waiting', () => {
|
||||
expect(isTimeout(Object.assign(new Error(''), { name: 'TimeoutError' }))).toBe(true);
|
||||
expect(isTimeout(Object.assign(new Error(''), { name: 'AbortError' }))).toBe(true);
|
||||
expect(isTimeout(new TypeError('fetch failed'))).toBe(false);
|
||||
expect(isTimeout('not an error')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,13 @@ vi.mock('@/lib/env', () => ({
|
||||
return process.env.INSTALL_CONTENT_DIR;
|
||||
},
|
||||
},
|
||||
normalizeApiUrl: (raw: string) => {
|
||||
try {
|
||||
return new URL(raw).toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
let dir: string;
|
||||
|
||||
@@ -23,6 +23,15 @@ vi.mock('@/lib/env', () => ({
|
||||
return process.env.INSTALL_CONTENT_DIR || '';
|
||||
},
|
||||
},
|
||||
// Real implementation, not a stub: install.ts's own normalisation tests
|
||||
// (#634) rely on this actually stripping trailing slashes.
|
||||
normalizeApiUrl: (raw: string) => {
|
||||
try {
|
||||
return new URL(raw).toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
/** Import fresh so the module-level install-file cache cannot leak between cases. */
|
||||
@@ -103,6 +112,45 @@ describe('getInstallCredentials', () => {
|
||||
adminPassword: '',
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The environment path already strips a trailing slash (lib/env.ts); the
|
||||
* file path used to store content/install.json's apiUrl completely
|
||||
* unnormalised. `https://host/api/` then reached getConfig() as
|
||||
* `.../api/`, which does not `endsWith('/api')`, so it became
|
||||
* `.../api//api` and every request 404ed (#634). Normalising on every read
|
||||
* means an install.json written before this fix heals without re-running
|
||||
* the wizard.
|
||||
*/
|
||||
describe('heals an unnormalised URL already on disk (#634)', () => {
|
||||
it.each([
|
||||
['trailing slash, no /api', 'https://file-immich:2283/', 'https://file-immich:2283'],
|
||||
[
|
||||
'trailing slash, with /api',
|
||||
'https://file-immich:2283/api/',
|
||||
'https://file-immich:2283/api',
|
||||
],
|
||||
['no trailing slash, already clean', 'https://file-immich:2283', 'https://file-immich:2283'],
|
||||
])('file path: %s', async (_name, stored, expected) => {
|
||||
const { getInstallCredentials } = await loadInstall();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'install.json'),
|
||||
JSON.stringify({ apiUrl: stored, apiKey: 'file-key' }),
|
||||
);
|
||||
|
||||
expect(getInstallCredentials().apiUrl).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['trailing slash, no /api', 'https://env-immich:2283/', 'https://env-immich:2283'],
|
||||
['trailing slash, with /api', 'https://env-immich:2283/api/', 'https://env-immich:2283/api'],
|
||||
])('env path: %s', async (_name, envValue, expected) => {
|
||||
const { getInstallCredentials } = await loadInstall();
|
||||
process.env.__T_INSTALL_URL = envValue;
|
||||
|
||||
expect(getInstallCredentials().apiUrl).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInstalled', () => {
|
||||
@@ -168,6 +216,23 @@ describe('completeInstall', () => {
|
||||
expect(getInstallCredentials().authSecret).toBe(creds.authSecret);
|
||||
});
|
||||
|
||||
/**
|
||||
* The route verifies the connection against normalizeApiBase(apiUrl),
|
||||
* which strips a trailing slash, but this used to write input.apiUrl.trim()
|
||||
* unchanged — so a URL that passed the ping test was stored as typed and
|
||||
* every request afterwards 404ed (#634).
|
||||
*/
|
||||
it.each([
|
||||
['trailing slash, no /api', 'https://immich.example/', 'https://immich.example'],
|
||||
['trailing slash, with /api', 'https://immich.example/api/', 'https://immich.example/api'],
|
||||
])('normalises the stored apiUrl: %s', async (_name, typed, expected) => {
|
||||
const { completeInstall } = await loadInstall();
|
||||
await completeInstall({ apiUrl: typed, apiKey: 'api-key-123' });
|
||||
|
||||
const creds = JSON.parse(fs.readFileSync(path.join(dir, 'install.json'), 'utf8'));
|
||||
expect(creds.apiUrl).toBe(expected);
|
||||
});
|
||||
|
||||
it('writes an empty albums list when no albums are selected', async () => {
|
||||
const { completeInstall } = await loadInstall();
|
||||
await completeInstall({
|
||||
@@ -202,6 +267,68 @@ describe('completeInstall', () => {
|
||||
|
||||
expect(isInstalled()).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* isInstalled() is false whenever Immich credentials fail to resolve,
|
||||
* whether or not gallery.yaml exists — a bad IMMICH_API_URL or a corrupt
|
||||
* install.json is enough. Re-running the wizard after that used to replace
|
||||
* every subpage, section, password, assetOrder and per-album override with
|
||||
* a two-key skeleton (#627).
|
||||
*/
|
||||
it('does not overwrite an existing gallery.yaml', async () => {
|
||||
const { completeInstall } = await loadInstall();
|
||||
const galleryPath = path.join(dir, 'gallery.yaml');
|
||||
const existing = 'albums:\n - existing-album\nsubpages:\n - slug: trips\n';
|
||||
fs.writeFileSync(galleryPath, existing);
|
||||
|
||||
const result = await completeInstall({
|
||||
apiUrl: 'https://immich.example',
|
||||
apiKey: 'api-key-123',
|
||||
albums: [ALBUM_ID],
|
||||
});
|
||||
|
||||
expect(result.galleryWritten).toBe(false);
|
||||
expect(fs.readFileSync(galleryPath, 'utf8')).toBe(existing);
|
||||
});
|
||||
|
||||
it('does not overwrite an existing settings.yaml', async () => {
|
||||
const { completeInstall } = await loadInstall();
|
||||
const settingsPath = path.join(dir, 'settings.yaml');
|
||||
const existing = 'title: "Existing Site"\n';
|
||||
fs.writeFileSync(settingsPath, existing);
|
||||
|
||||
const result = await completeInstall({
|
||||
apiUrl: 'https://immich.example',
|
||||
apiKey: 'api-key-123',
|
||||
siteTitle: 'New Title',
|
||||
});
|
||||
|
||||
expect(result.settingsWritten).toBe(false);
|
||||
expect(fs.readFileSync(settingsPath, 'utf8')).toBe(existing);
|
||||
});
|
||||
|
||||
it('writes both files on a genuinely fresh install and reports it', async () => {
|
||||
const { completeInstall } = await loadInstall();
|
||||
|
||||
const result = await completeInstall({
|
||||
apiUrl: 'https://immich.example',
|
||||
apiKey: 'api-key-123',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ galleryWritten: true, settingsWritten: true });
|
||||
});
|
||||
|
||||
it('still writes fresh credentials into install.json when gallery.yaml is kept', async () => {
|
||||
const { completeInstall, getInstallCredentials } = await loadInstall();
|
||||
fs.writeFileSync(path.join(dir, 'gallery.yaml'), 'albums: []\n');
|
||||
|
||||
await completeInstall({
|
||||
apiUrl: 'https://immich.example',
|
||||
apiKey: 'fresh-key',
|
||||
});
|
||||
|
||||
expect(getInstallCredentials().apiKey).toBe('fresh-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeApiBase', () => {
|
||||
|
||||
@@ -14,12 +14,14 @@ import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'fs/promises';
|
||||
let root: string;
|
||||
let journalDir: string;
|
||||
let backupDir: string;
|
||||
let essaysDir: string;
|
||||
let service: typeof import('../admin/journal-service');
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(os.tmpdir(), 'folio-journal-'));
|
||||
journalDir = path.join(root, 'content', 'journal');
|
||||
backupDir = path.join(journalDir, '.backups');
|
||||
essaysDir = path.join(root, 'content', 'essays');
|
||||
await mkdir(journalDir, { recursive: true });
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(root);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
@@ -62,6 +64,56 @@ describe('deleteJournalEntry', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeJournalEntry backup failure (#630)', () => {
|
||||
it('still saves a brand-new entry with no backup', async () => {
|
||||
await expect(service.writeJournalEntry('trip', entry('New'))).resolves.toBeUndefined();
|
||||
await expect(readdir(backupDir).catch(() => [])).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('aborts the save when the entry exists but the backup cannot be written', async () => {
|
||||
await writeFile(path.join(journalDir, 'trip.md'), entry('Old'));
|
||||
// A file where the backup directory should be makes mkdir and copy fail.
|
||||
await writeFile(backupDir, 'not a directory');
|
||||
|
||||
await expect(service.writeJournalEntry('trip', entry('New'))).rejects.toThrow();
|
||||
await expect(readFile(path.join(journalDir, 'trip.md'), 'utf8')).resolves.toBe(entry('Old'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy content/essays/ entries (#631)', () => {
|
||||
it('retires the legacy copy once a legacy entry is edited', async () => {
|
||||
await mkdir(essaysDir, { recursive: true });
|
||||
await writeFile(path.join(essaysDir, 'trip.md'), entry('Old'));
|
||||
|
||||
await service.writeJournalEntry('trip', entry('New'));
|
||||
|
||||
await expect(readdir(essaysDir)).resolves.not.toContain('trip.md');
|
||||
await expect(readFile(path.join(journalDir, 'trip.md'), 'utf8')).resolves.toBe(entry('New'));
|
||||
});
|
||||
|
||||
it('does not resurrect a legacy entry after it is edited then deleted', async () => {
|
||||
await mkdir(essaysDir, { recursive: true });
|
||||
await writeFile(path.join(essaysDir, 'trip.md'), entry('Old'));
|
||||
|
||||
await service.writeJournalEntry('trip', entry('New'));
|
||||
await expect(service.deleteJournalEntry('trip')).resolves.toBe(true);
|
||||
|
||||
const entries = await service.listJournalEntries();
|
||||
expect(entries.map((e) => e.slug)).not.toContain('trip');
|
||||
});
|
||||
|
||||
it('deletes a legacy entry that was never edited, from content/essays/', async () => {
|
||||
await mkdir(essaysDir, { recursive: true });
|
||||
await writeFile(path.join(essaysDir, 'trip.md'), entry('Legacy'));
|
||||
|
||||
await expect(service.deleteJournalEntry('trip')).resolves.toBe(true);
|
||||
|
||||
await expect(readdir(essaysDir)).resolves.not.toContain('trip.md');
|
||||
const entries = await service.listJournalEntries();
|
||||
expect(entries.map((e) => e.slug)).not.toContain('trip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreJournalBackup', () => {
|
||||
it('brings a deleted entry back under its slug', async () => {
|
||||
await writeFile(path.join(journalDir, 'trip.md'), entry('Trip'));
|
||||
|
||||
92
lib/__tests__/journal-roundtrip.test.ts
Normal file
92
lib/__tests__/journal-roundtrip.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseJournalMarkdown, serializeJournalMarkdown } from '../journal';
|
||||
|
||||
/**
|
||||
* serializeJournalMarkdown must be the exact inverse of the inline renderer
|
||||
* for the constructs it produces, or a save silently rewrites the file (#628).
|
||||
*/
|
||||
const roundTrip = (source: string) => serializeJournalMarkdown(parseJournalMarkdown(source));
|
||||
|
||||
describe('journal round trip (#628)', () => {
|
||||
it('keeps a link through save', () => {
|
||||
const source = 'Read more at [Immich](https://immich.app) today.';
|
||||
const once = roundTrip(source);
|
||||
expect(once).toBe(source);
|
||||
expect(roundTrip(once)).toBe(once);
|
||||
});
|
||||
|
||||
it('keeps emphasis inside a quote as markdown, not literal tags', () => {
|
||||
const source = '> **Yes** he said -- Someone';
|
||||
const once = roundTrip(source);
|
||||
expect(once).toBe(source);
|
||||
expect(once).not.toContain('<strong>');
|
||||
});
|
||||
|
||||
it('keeps emphasis inside a photo caption as markdown', () => {
|
||||
const source = '';
|
||||
const once = roundTrip(source);
|
||||
expect(once).toBe(source);
|
||||
});
|
||||
|
||||
it('does not double-encode an apostrophe in a photo-pair caption', () => {
|
||||
const source = "";
|
||||
const once = roundTrip(source);
|
||||
expect(once).toBe(source);
|
||||
expect(roundTrip(once)).toBe(once);
|
||||
});
|
||||
|
||||
it('leaves intraword underscores alone', () => {
|
||||
const source = 'See my_photo_2024.jpg for details.';
|
||||
const once = roundTrip(source);
|
||||
expect(once).toBe(source);
|
||||
});
|
||||
|
||||
it('still renders underscore emphasis with word boundaries either side', () => {
|
||||
const source = 'This is _emphasised_ text.';
|
||||
const parsed = parseJournalMarkdown(source);
|
||||
expect(parsed.blocks[0]).toMatchObject({
|
||||
type: 'paragraph',
|
||||
html: 'This is <em>emphasised</em> text.',
|
||||
});
|
||||
// `_` and `*` both parse to <em>, so a `_`-emphasised word comes back with
|
||||
// `*` — that normalisation is expected. What must hold is idempotence.
|
||||
const once = roundTrip(source);
|
||||
expect(roundTrip(once)).toBe(once);
|
||||
});
|
||||
|
||||
it('round-trips a link nested inside bold text', () => {
|
||||
const source = '**[Immich](https://immich.app)** is great.';
|
||||
const once = roundTrip(source);
|
||||
expect(once).toBe(source);
|
||||
});
|
||||
|
||||
it('round-trips bold text nested inside a link label', () => {
|
||||
const source = '[**Immich**](https://immich.app) is great.';
|
||||
const once = roundTrip(source);
|
||||
expect(once).toBe(source);
|
||||
});
|
||||
|
||||
it('is idempotent on a full multi-block entry', () => {
|
||||
const source = [
|
||||
'---',
|
||||
'title: "A trip"',
|
||||
'---',
|
||||
'',
|
||||
'# Heading',
|
||||
'',
|
||||
'Read more at [Immich](https://immich.app), it is **great** and *fun*.',
|
||||
'',
|
||||
'> **Yes** he said -- Someone',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
"",
|
||||
].join('\n');
|
||||
|
||||
const once = roundTrip(source);
|
||||
const twice = roundTrip(once);
|
||||
expect(twice).toBe(once);
|
||||
expect(once).toContain('[Immich](https://immich.app)');
|
||||
expect(once).toContain("Anna's photos");
|
||||
});
|
||||
});
|
||||
51
lib/__tests__/journalNav.test.ts
Normal file
51
lib/__tests__/journalNav.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { journalNeighbours } from '@/lib/journalNav';
|
||||
|
||||
const entry = (slug: string, title = slug.toUpperCase()) => ({ slug, title });
|
||||
|
||||
const three = [entry('a'), entry('b'), entry('c')];
|
||||
|
||||
describe('journalNeighbours', () => {
|
||||
it('offers both neighbours in the middle of a list', () => {
|
||||
expect(journalNeighbours(three, 'b')).toEqual({
|
||||
prev: { href: '/journal/a', title: 'A' },
|
||||
next: { href: '/journal/c', title: 'C' },
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the previous link on the first entry', () => {
|
||||
expect(journalNeighbours(three, 'a')).toEqual({ next: { href: '/journal/b', title: 'B' } });
|
||||
});
|
||||
|
||||
it('omits the next link on the last entry — no wrap-around', () => {
|
||||
expect(journalNeighbours(three, 'c')).toEqual({ prev: { href: '/journal/b', title: 'B' } });
|
||||
});
|
||||
|
||||
it('returns nothing for a single-entry list', () => {
|
||||
expect(journalNeighbours([entry('a')], 'a')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns nothing for an empty list', () => {
|
||||
expect(journalNeighbours([], 'a')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns nothing when the entry is not in the list', () => {
|
||||
expect(journalNeighbours(three, 'zzz')).toEqual({});
|
||||
});
|
||||
|
||||
it('follows list order, not slug or title order', () => {
|
||||
const shuffled = [entry('c'), entry('a'), entry('b')];
|
||||
expect(journalNeighbours(shuffled, 'a')).toEqual({
|
||||
prev: { href: '/journal/c', title: 'C' },
|
||||
next: { href: '/journal/b', title: 'B' },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to whatever title is passed in, unchanged', () => {
|
||||
const list = [entry('untitled-entry', 'untitled-entry'), entry('b')];
|
||||
expect(journalNeighbours(list, 'b').prev).toEqual({
|
||||
href: '/journal/untitled-entry',
|
||||
title: 'untitled-entry',
|
||||
});
|
||||
});
|
||||
});
|
||||
85
lib/__tests__/mapService.test.ts
Normal file
85
lib/__tests__/mapService.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* `!exif?.latitude` treats a coordinate of exactly 0 as absent, so a photo on
|
||||
* the equator or the prime meridian never reached the map (#635).
|
||||
*/
|
||||
const getAlbums = vi.fn();
|
||||
const getAlbum = vi.fn();
|
||||
vi.mock('@/lib/immich', () => ({
|
||||
immich: {
|
||||
getAlbums: (...args: unknown[]) => getAlbums(...args),
|
||||
getAlbum: (...args: unknown[]) => getAlbum(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => ({ subpages: [], cacheTtl: 60_000 }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/cache', () => ({
|
||||
cache: { get: () => undefined, set: () => {} },
|
||||
}));
|
||||
|
||||
import { getMapData } from '@/lib/mapService';
|
||||
|
||||
const ALBUM = { id: 'album-1', albumName: 'Trip', slug: 'trip' };
|
||||
|
||||
function asset(id: string, exif: Record<string, unknown>) {
|
||||
return { id, exifInfo: exif };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getAlbums.mockReset();
|
||||
getAlbum.mockReset();
|
||||
getAlbums.mockResolvedValue([ALBUM]);
|
||||
});
|
||||
|
||||
describe('getMapData coordinate filtering (#635)', () => {
|
||||
it('includes a photo at latitude 0 (the equator)', async () => {
|
||||
getAlbum.mockResolvedValue({
|
||||
id: ALBUM.id,
|
||||
assets: [asset('a', { latitude: 0, longitude: 12.5, city: 'Somewhere', country: 'Kenya' })],
|
||||
});
|
||||
|
||||
const locations = await getMapData();
|
||||
|
||||
expect(locations).toHaveLength(1);
|
||||
expect(locations[0].albums[0].photoCount).toBe(1);
|
||||
expect(locations[0].albums[0].latSum).toBe(0);
|
||||
});
|
||||
|
||||
it('includes a photo at longitude 0 (the prime meridian)', async () => {
|
||||
getAlbum.mockResolvedValue({
|
||||
id: ALBUM.id,
|
||||
assets: [asset('a', { latitude: 51.5, longitude: 0, city: 'London', country: 'UK' })],
|
||||
});
|
||||
|
||||
const locations = await getMapData();
|
||||
|
||||
expect(locations).toHaveLength(1);
|
||||
expect(locations[0].albums[0].photoCount).toBe(1);
|
||||
});
|
||||
|
||||
it('still excludes a photo with no coordinates at all', async () => {
|
||||
getAlbum.mockResolvedValue({
|
||||
id: ALBUM.id,
|
||||
assets: [asset('a', { latitude: null, longitude: null, city: 'Nowhere', country: 'None' })],
|
||||
});
|
||||
|
||||
const locations = await getMapData();
|
||||
|
||||
expect(locations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still excludes a photo missing city or country', async () => {
|
||||
getAlbum.mockResolvedValue({
|
||||
id: ALBUM.id,
|
||||
assets: [asset('a', { latitude: 0, longitude: 0, city: '', country: 'Kenya' })],
|
||||
});
|
||||
|
||||
const locations = await getMapData();
|
||||
|
||||
expect(locations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
56
lib/__tests__/navActive.test.ts
Normal file
56
lib/__tests__/navActive.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isActivePath } from '@/components/NavLink';
|
||||
|
||||
/**
|
||||
* The header has styled `.active` since it was written and never applied it.
|
||||
* These pin the matching rules, in particular the two that are easy to get
|
||||
* wrong: Home must not light up everywhere, and one subpage slug must not
|
||||
* match another that merely starts the same way.
|
||||
*/
|
||||
describe('isActivePath', () => {
|
||||
it('marks Home only on the home page', () => {
|
||||
expect(isActivePath('/', '/')).toBe(true);
|
||||
expect(isActivePath('/reisen', '/')).toBe(false);
|
||||
expect(isActivePath('/journal/island', '/')).toBe(false);
|
||||
});
|
||||
|
||||
it('marks a subpage on its own page', () => {
|
||||
expect(isActivePath('/reisen', '/reisen')).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a subpage while inside one of its albums', () => {
|
||||
// An album lives at /<subpage>/<album>, and the visitor is still "in" that
|
||||
// section of the site.
|
||||
expect(isActivePath('/reisen/island', '/reisen')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not let one slug match another that starts the same way', () => {
|
||||
expect(isActivePath('/reisender', '/reisen')).toBe(false);
|
||||
expect(isActivePath('/reisen-2026', '/reisen')).toBe(false);
|
||||
});
|
||||
|
||||
it('marks Journal on an entry as well as the index', () => {
|
||||
expect(isActivePath('/journal', '/journal')).toBe(true);
|
||||
expect(isActivePath('/journal/eine-reise', '/journal')).toBe(true);
|
||||
});
|
||||
|
||||
it('marks nothing when the path is unknown', () => {
|
||||
expect(isActivePath(null, '/about')).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves unrelated links alone', () => {
|
||||
expect(isActivePath('/about', '/map')).toBe(false);
|
||||
expect(isActivePath('/map', '/about')).toBe(false);
|
||||
});
|
||||
|
||||
it('marks exactly one link for a typical header', () => {
|
||||
const header = ['/', '/reisen', '/journal', '/about', '/map'];
|
||||
const activeOn = (pathname: string) => header.filter((h) => isActivePath(pathname, h));
|
||||
|
||||
expect(activeOn('/')).toEqual(['/']);
|
||||
expect(activeOn('/reisen')).toEqual(['/reisen']);
|
||||
expect(activeOn('/reisen/island')).toEqual(['/reisen']);
|
||||
expect(activeOn('/journal/eine-reise')).toEqual(['/journal']);
|
||||
expect(activeOn('/impressum')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parsePhotoHash, buildPhotoHash, buildPhotoPermalink } from '@/lib/photoHash';
|
||||
import {
|
||||
parsePhotoHash,
|
||||
buildPhotoHash,
|
||||
parsePhotoQuery,
|
||||
buildPhotoQuery,
|
||||
buildPhotoPermalink,
|
||||
} from '@/lib/photoHash';
|
||||
|
||||
describe('buildPhotoHash', () => {
|
||||
it('presents the index 1-indexed', () => {
|
||||
@@ -33,6 +39,46 @@ describe('parsePhotoHash', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePhotoQuery', () => {
|
||||
it('reads the photo param', () => {
|
||||
expect(parsePhotoQuery('?photo=v2:abc123')).toBe('v2:abc123');
|
||||
});
|
||||
|
||||
it('returns null when absent', () => {
|
||||
expect(parsePhotoQuery('')).toBeNull();
|
||||
expect(parsePhotoQuery('?preview=true')).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores an empty value', () => {
|
||||
// URLSearchParams reads `?photo=` as the empty string, not absent — callers
|
||||
// treat an empty token the same as "not addressing a photo" downstream,
|
||||
// but this function reports exactly what was in the URL.
|
||||
expect(parsePhotoQuery('?photo=')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPhotoQuery', () => {
|
||||
it('adds the photo param to an empty search string', () => {
|
||||
expect(buildPhotoQuery('', 'v2:abc')).toBe('?photo=v2%3Aabc');
|
||||
});
|
||||
|
||||
it('preserves other query parameters', () => {
|
||||
expect(buildPhotoQuery('?preview=true', 'v2:abc')).toBe('?preview=true&photo=v2%3Aabc');
|
||||
});
|
||||
|
||||
it('replaces an existing photo param rather than duplicating it', () => {
|
||||
expect(buildPhotoQuery('?photo=old', 'v2:new')).toBe('?photo=v2%3Anew');
|
||||
});
|
||||
|
||||
it('removes the param when assetId is null', () => {
|
||||
expect(buildPhotoQuery('?photo=v2:abc&preview=true', null)).toBe('?preview=true');
|
||||
});
|
||||
|
||||
it('returns an empty string when nothing is left', () => {
|
||||
expect(buildPhotoQuery('?photo=v2:abc', null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPhotoPermalink', () => {
|
||||
const location = {
|
||||
origin: 'https://folio.example',
|
||||
@@ -40,19 +86,30 @@ describe('buildPhotoPermalink', () => {
|
||||
search: '',
|
||||
};
|
||||
|
||||
it('builds an absolute link to the photo', () => {
|
||||
expect(buildPhotoPermalink(location, 2)).toBe('https://folio.example/travel/iceland#photo-3');
|
||||
it('builds an absolute link to the photo, addressed by its stable id', () => {
|
||||
expect(buildPhotoPermalink(location, 'v2:abc123')).toBe(
|
||||
'https://folio.example/travel/iceland?photo=v2%3Aabc123',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves an existing query string', () => {
|
||||
expect(buildPhotoPermalink({ ...location, search: '?preview=true' }, 0)).toBe(
|
||||
'https://folio.example/travel/iceland?preview=true#photo-1',
|
||||
expect(buildPhotoPermalink({ ...location, search: '?preview=true' }, 'v2:abc123')).toBe(
|
||||
'https://folio.example/travel/iceland?preview=true&photo=v2%3Aabc123',
|
||||
);
|
||||
});
|
||||
|
||||
it('works at the site root', () => {
|
||||
expect(buildPhotoPermalink({ ...location, pathname: '/' }, 0)).toBe(
|
||||
'https://folio.example/#photo-1',
|
||||
expect(buildPhotoPermalink({ ...location, pathname: '/' }, 'v2:abc123')).toBe(
|
||||
'https://folio.example/?photo=v2%3Aabc123',
|
||||
);
|
||||
});
|
||||
|
||||
// The whole point: the same asset keeps the same link no matter where it
|
||||
// now sits in the album (#588). The old #photo-N form could not promise
|
||||
// that at all — it named a position, not a photo.
|
||||
it('does not change when the asset moves to a different position', () => {
|
||||
expect(buildPhotoPermalink(location, 'v2:abc123')).toBe(
|
||||
buildPhotoPermalink(location, 'v2:abc123'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock('@/lib/auth', () => ({
|
||||
}));
|
||||
|
||||
import { isSiteUnlocked } from '@/lib/auth';
|
||||
import { tryToParsePath } from 'next/dist/lib/try-to-parse-path';
|
||||
import { proxy, config } from '@/proxy';
|
||||
|
||||
const mockUnlocked = isSiteUnlocked as unknown as ReturnType<typeof vi.fn>;
|
||||
@@ -88,6 +89,53 @@ describe('proxy', () => {
|
||||
expect(source).not.toContain('admin');
|
||||
});
|
||||
|
||||
/**
|
||||
* Reading the pattern is not enough: whether a path reaches proxy() is
|
||||
* decided by the regex Next compiles from it, so that is what gets tested.
|
||||
* A path the matcher skips gets no site gate and no CSP.
|
||||
*/
|
||||
describe('matcher, compiled the way Next compiles it', () => {
|
||||
const compiled = tryToParsePath(config.matcher[0]);
|
||||
const reaches = (pathname: string) => new RegExp(compiled.regexStr!).test(pathname);
|
||||
|
||||
it('compiles', () => {
|
||||
expect(compiled.error).toBeUndefined();
|
||||
expect(compiled.regexStr).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each(['/', '/journal', '/japan/kyoto', '/admin', '/admin/pages', '/gate', '/install'])(
|
||||
'runs on %s',
|
||||
(pathname) => expect(reaches(pathname)).toBe(true),
|
||||
);
|
||||
|
||||
it.each([
|
||||
'/api',
|
||||
'/api/image/v2:abc',
|
||||
'/_next/static/chunks/main.js',
|
||||
'/_next/image',
|
||||
'/favicon.ico',
|
||||
'/sitemap.xml',
|
||||
'/robots.txt',
|
||||
])('skips %s', (pathname) => expect(reaches(pathname)).toBe(false));
|
||||
|
||||
// An exclusion is a whole segment or a whole filename, never a prefix: a
|
||||
// page whose slug happens to start with one must still be gated.
|
||||
it.each([
|
||||
'/apia-samoa',
|
||||
'/apiary',
|
||||
'/api-docs',
|
||||
'/_next/staticfoo',
|
||||
'/_next/imagery',
|
||||
'/sitemapXxml',
|
||||
'/robots1txt',
|
||||
'/faviconXico',
|
||||
'/robots.txt.bak',
|
||||
'/sitemap.xml/extra',
|
||||
])('runs on %s, which only looks like an exclusion', (pathname) =>
|
||||
expect(reaches(pathname)).toBe(true),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The gate resolves the site password through getConfig(), which throws on a
|
||||
* config it cannot parse — inside the proxy, before app/layout.tsx can fall
|
||||
|
||||
@@ -252,3 +252,77 @@ describe('checkRateLimit', () => {
|
||||
expect(result.remaining).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The previous limiter reset its whole budget the moment `expiresAt` (set
|
||||
* when the window opened, never advanced) passed. A burst timed around that
|
||||
* boundary — most of one window, then most of the next — passed roughly
|
||||
* double the documented limit. 10 admin password attempts landed in one
|
||||
* second instead of 5 per minute.
|
||||
*/
|
||||
describe('checkRateLimit is a sliding window, not a fixed one', () => {
|
||||
let testIp: string;
|
||||
let counter = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
counter++;
|
||||
testIp = `sliding-ip-${counter}-${Date.now()}`;
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('does not grant a fresh budget the instant a window rolls over', () => {
|
||||
const maxRpm = 10;
|
||||
for (let i = 0; i < maxRpm; i++) checkRateLimit(testIp, maxRpm);
|
||||
expect(checkRateLimit(testIp, maxRpm).success).toBe(false);
|
||||
|
||||
// Exactly one window later: a fixed window resets to a full fresh
|
||||
// budget here. The previous window's requests still fully overlap the
|
||||
// trailing 60s at this exact instant, so this must still be refused.
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(checkRateLimit(testIp, maxRpm).success).toBe(false);
|
||||
});
|
||||
|
||||
it('never lets a boundary-timed burst clear roughly double the limit', () => {
|
||||
const maxRpm = 10;
|
||||
// Spend the whole budget right at the end of the first window.
|
||||
vi.advanceTimersByTime(59_000);
|
||||
for (let i = 0; i < maxRpm; i++) checkRateLimit(testIp, maxRpm);
|
||||
|
||||
// Cross into the next window and try to spend the whole budget again,
|
||||
// one request per second — a fixed window allows every one of these.
|
||||
let allowedInSecondWindow = 0;
|
||||
for (let i = 0; i < maxRpm; i++) {
|
||||
vi.advanceTimersByTime(1000);
|
||||
if (checkRateLimit(testIp, maxRpm).success) allowedInSecondWindow++;
|
||||
}
|
||||
|
||||
// A tolerant bound, not an exact one: the point is "nowhere near double",
|
||||
// not pinning the estimator's exact rounding.
|
||||
expect(allowedInSecondWindow).toBeLessThan(maxRpm);
|
||||
});
|
||||
|
||||
it('fully forgets a client after two windows of inactivity', () => {
|
||||
const maxRpm = 3;
|
||||
for (let i = 0; i < maxRpm; i++) checkRateLimit(testIp, maxRpm);
|
||||
expect(checkRateLimit(testIp, maxRpm).success).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(2 * 60_000 + 1);
|
||||
const result = checkRateLimit(testIp, maxRpm);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.remaining).toBe(maxRpm - 1);
|
||||
});
|
||||
|
||||
it('lets the budget recover gradually as the old window ages out', () => {
|
||||
const maxRpm = 10;
|
||||
for (let i = 0; i < maxRpm; i++) checkRateLimit(testIp, maxRpm);
|
||||
expect(checkRateLimit(testIp, maxRpm).success).toBe(false);
|
||||
|
||||
// Halfway through the next window, roughly half of the previous burst has
|
||||
// aged out of the trailing 60s — some budget should have come back.
|
||||
vi.advanceTimersByTime(90_000);
|
||||
const result = checkRateLimit(testIp, maxRpm);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
124
lib/__tests__/settings-schema.test.ts
Normal file
124
lib/__tests__/settings-schema.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import { validateSettings } from '../config/settingsSchema';
|
||||
|
||||
/**
|
||||
* Half of these tests are about what must *not* be rejected.
|
||||
*
|
||||
* The failure mode of a validator on a config file is not "it let something
|
||||
* through" — it is "it locked an operator out of their own settings, through
|
||||
* the panel that writes the file". There is no schemaVersion and no migration
|
||||
* code, so older and newer configurations are both in the wild.
|
||||
*/
|
||||
|
||||
const ok = (input: unknown) => validateSettings(input).ok;
|
||||
const errors = (input: unknown) => {
|
||||
const result = validateSettings(input);
|
||||
return result.ok ? [] : result.errors;
|
||||
};
|
||||
|
||||
describe('validateSettings — accepts what works today', () => {
|
||||
it('accepts the example settings file this repo ships with', () => {
|
||||
// content/settings.yaml is gitignored — the example is the one every
|
||||
// installation starts from, and the one CI actually has.
|
||||
const raw = fs.readFileSync(path.join(process.cwd(), 'content/settings.yaml.example'), 'utf8');
|
||||
|
||||
expect(validateSettings(yaml.load(raw))).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('accepts an empty settings file', () => {
|
||||
// Everything has a default; a site with no settings.yaml is a valid site.
|
||||
expect(ok({})).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts both spellings of theme', () => {
|
||||
expect(ok({ theme: 'minimal' })).toBe(true);
|
||||
expect(ok({ theme: { preset: 'minimal', accent: '#c0ffee', radius: 4 } })).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a watermark opacity written as a percentage', () => {
|
||||
// The #508 workaround: values above 1 are read as percentages. A validator
|
||||
// that insisted on 0–1 would reject configurations that render correctly.
|
||||
expect(ok({ watermark: { opacity: 90 } })).toBe(true);
|
||||
expect(ok({ watermark: { opacity: 0.5 } })).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a colour mode it does not recognise', () => {
|
||||
// resolveColorMode falls back to 'dark'. Rejecting here would be stricter
|
||||
// than the behaviour it guards.
|
||||
expect(ok({ mode: 'sepia' })).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts keys this version has never heard of', () => {
|
||||
expect(ok({ title: 'Folio', somethingFromANewerVersion: { nested: true } })).toBe(true);
|
||||
expect(ok({ theme: { preset: 'noir', futureKnob: 3 } })).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts the legacy exifOnHover switch alongside the group form', () => {
|
||||
expect(ok({ exifOnHover: false, exif: { camera: true, caption: false } })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSettings — rejects what breaks the site', () => {
|
||||
it('rejects a payload that is not an object', () => {
|
||||
expect(ok('title: Folio')).toBe(false);
|
||||
expect(ok([{ title: 'Folio' }])).toBe(false);
|
||||
expect(ok(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a scalar where a section belongs', () => {
|
||||
// `grid: 3` reads as "three columns" to a human and as a crash to
|
||||
// buildCoverGridVars.
|
||||
const found = errors({ grid: 3 });
|
||||
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0].field).toBe('grid');
|
||||
});
|
||||
|
||||
it('rejects a section where a scalar belongs', () => {
|
||||
const found = errors({ title: { de: 'Folio', en: 'Folio' } });
|
||||
|
||||
expect(found[0].field).toBe('title');
|
||||
});
|
||||
|
||||
it('names the exact field, however deep', () => {
|
||||
const found = errors({ theme: { fonts: { heading: 42 } } });
|
||||
|
||||
expect(found.map((e) => e.field)).toContain('theme.fonts.heading');
|
||||
});
|
||||
|
||||
it('rejects a theme that is neither a preset name nor a section', () => {
|
||||
// The separate theme check must not turn into a hole: anything that is not
|
||||
// a string still has to look like the object form.
|
||||
expect(ok({ theme: 3 })).toBe(false);
|
||||
expect(ok({ theme: ['minimal'] })).toBe(false);
|
||||
expect(errors({ theme: 3 })[0].field).toBe('theme');
|
||||
});
|
||||
|
||||
it('names an index inside navLinks', () => {
|
||||
const found = errors({ navLinks: [{ label: 'Shop', url: 'https://example.com' }, 'nope'] });
|
||||
|
||||
expect(found[0].field).toBe('navLinks.1');
|
||||
});
|
||||
|
||||
it('rejects a radius written with its unit', () => {
|
||||
// #633: `radius: 8px` reaches CSS as `8pxpx`. The value itself belongs to
|
||||
// the resolver, but a string where a number belongs is a type error, and
|
||||
// that half is caught here for anything saved from the panel.
|
||||
const found = errors({ theme: { radius: '8px' } });
|
||||
|
||||
expect(found.map((e) => e.field)).toContain('theme.radius');
|
||||
});
|
||||
|
||||
it('rejects a list where a record belongs', () => {
|
||||
expect(ok({ legal: ['Ralf', 'Berlin'] })).toBe(false);
|
||||
});
|
||||
|
||||
it('reports every offending field at once, not just the first', () => {
|
||||
const found = errors({ title: 1, subtitle: 2, map: 'yes' });
|
||||
|
||||
expect(found.map((e) => e.field).sort()).toEqual(['map', 'subtitle', 'title']);
|
||||
});
|
||||
});
|
||||
62
lib/__tests__/theme-scalars.test.ts
Normal file
62
lib/__tests__/theme-scalars.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveTheme, THEME_PRESETS, DEFAULT_PRESET } from '@/lib/config/theme';
|
||||
|
||||
/**
|
||||
* `radius` reaches CSS as `${radius}px` and `${radius * 1.5}px` (app/layout.tsx),
|
||||
* so a value that is not actually a number does not fail loudly — it becomes
|
||||
* `8pxpx` and `NaNpx` (#633). Hand-edited YAML can hand resolveTheme anything;
|
||||
* only the panel-save boundary (settingsSchema.ts) already caught the type
|
||||
* half of this, and only for a save from the panel.
|
||||
*/
|
||||
describe('resolveTheme scalar coercion (#633)', () => {
|
||||
const base = THEME_PRESETS[DEFAULT_PRESET];
|
||||
|
||||
it('falls back to the preset radius when the value is a string', () => {
|
||||
// as unknown as number: simulating hand-edited YAML, which yaml.load()
|
||||
// hands back with no runtime guarantee it matches SettingsYaml's types.
|
||||
const theme = resolveTheme({ radius: '8px' as unknown as number });
|
||||
expect(theme.radius).toBe(base.radius);
|
||||
expect(Number.isFinite(theme.radius)).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the preset radius when the value is NaN', () => {
|
||||
expect(resolveTheme({ radius: NaN }).radius).toBe(base.radius);
|
||||
});
|
||||
|
||||
it('clamps an out-of-range radius rather than passing it through', () => {
|
||||
expect(resolveTheme({ radius: 999 }).radius).toBeLessThanOrEqual(64);
|
||||
expect(resolveTheme({ radius: -5 }).radius).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps a legitimate radius override', () => {
|
||||
expect(resolveTheme({ radius: 24 }).radius).toBe(24);
|
||||
});
|
||||
|
||||
it('falls back to the preset accent when the value is not a string', () => {
|
||||
const theme = resolveTheme({ accent: 42 as unknown as string });
|
||||
expect(theme.accent).toBe(base.accent);
|
||||
});
|
||||
|
||||
it('falls back to the preset accent for an empty string', () => {
|
||||
expect(resolveTheme({ accent: '' }).accent).toBe(base.accent);
|
||||
});
|
||||
|
||||
it('keeps a legitimate accent override', () => {
|
||||
expect(resolveTheme({ accent: '#123456' }).accent).toBe('#123456');
|
||||
});
|
||||
|
||||
it('falls back to the preset grain/headerDot when not a boolean', () => {
|
||||
const theme = resolveTheme({
|
||||
grain: 'yes' as unknown as boolean,
|
||||
headerDot: 1 as unknown as boolean,
|
||||
});
|
||||
expect(theme.grain).toBe(base.grain);
|
||||
expect(theme.headerDot).toBe(base.headerDot);
|
||||
});
|
||||
|
||||
it('keeps legitimate grain/headerDot overrides', () => {
|
||||
const theme = resolveTheme({ grain: !base.grain, headerDot: !base.headerDot });
|
||||
expect(theme.grain).toBe(!base.grain);
|
||||
expect(theme.headerDot).toBe(!base.headerDot);
|
||||
});
|
||||
});
|
||||
@@ -80,3 +80,36 @@ describe('writeYamlFile temp file handling', () => {
|
||||
expect(fs.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* "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('writeYamlFile backup failure', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('still saves a brand-new file with no backup', async () => {
|
||||
await expect(writeGalleryYaml(gallery)).resolves.toBeUndefined();
|
||||
expect(fs.copyFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts the save when the file exists but the backup copy fails', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValueOnce(undefined);
|
||||
vi.mocked(fs.copyFile).mockRejectedValueOnce(
|
||||
Object.assign(new Error('EACCES'), { code: 'EACCES' }),
|
||||
);
|
||||
|
||||
await expect(writeGalleryYaml(gallery)).rejects.toThrow('EACCES');
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts when checking whether the file exists fails for a reason other than ENOENT', async () => {
|
||||
vi.mocked(fs.access).mockRejectedValueOnce(
|
||||
Object.assign(new Error('EACCES'), { code: 'EACCES' }),
|
||||
);
|
||||
|
||||
await expect(writeGalleryYaml(gallery)).rejects.toThrow('EACCES');
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user