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:
Ralksta
2026-09-22 14:12:13 +02:00
committed by GitHub
parent c3f92ca4ec
commit a3a6319c29
120 changed files with 6818 additions and 975 deletions

View File

@@ -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);
});

View 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
});
});
});

View File

@@ -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]);
});
});

View 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);
});
});

View File

@@ -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();
});
});

View File

@@ -10,6 +10,7 @@ vi.mock('@/lib/env', () => ({
CACHE_TTL: 300,
RATE_LIMIT_RPM: 120,
},
normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''),
}));
vi.mock('@/lib/secret', () => ({

View File

@@ -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', () => ({

View File

@@ -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', () => ({

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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');

View 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);
});
});

View File

@@ -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');
});
});
});

View File

@@ -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();
});
});

View 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);
});
});

View 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);
});
});

View File

@@ -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;

View File

@@ -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', () => {

View File

@@ -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'));

View 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 = '![abc123:wide](A **bold** caption)';
const once = roundTrip(source);
expect(once).toBe(source);
});
it('does not double-encode an apostrophe in a photo-pair caption', () => {
const source = "![abc123, def456](Anna's photos)";
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',
'',
'![abc123:wide](A **bold** caption)',
'',
"![abc123, def456](Anna's photos)",
].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");
});
});

View 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',
});
});
});

View 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);
});
});

View 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([]);
});
});

View File

@@ -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'),
);
});
});

View File

@@ -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

View File

@@ -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);
});
});

View 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']);
});
});

View 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);
});
});

View File

@@ -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();
});
});