release: v0.12.0 (#520)

Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes.
This commit is contained in:
Ralksta
2026-08-23 23:18:14 +02:00
committed by GitHub
parent 2f546f440d
commit 10ec68f493
119 changed files with 6888 additions and 758 deletions

View File

@@ -71,6 +71,23 @@ describe('admin session tokens', () => {
expect(verifyAdminToken(createAdminToken())).toBe(true);
});
/**
* A cookie far longer than any token we issue is rejected before it is
* parsed. The decode below the signature check was never reachable without
* the key, so this is boundary hygiene rather than a fix for an exploitable
* exhaustion — but a bound on untrusted input costs nothing (#505).
*/
it('rejects an implausibly long token without parsing it', async () => {
const { createAdminToken, verifyAdminToken } = await loadAuth();
const valid = createAdminToken();
expect(valid.length).toBeLessThan(512);
expect(verifyAdminToken(valid)).toBe(true);
expect(verifyAdminToken('a'.repeat(513))).toBe(false);
// Padding a genuine token past the bound must not sneak through either.
expect(verifyAdminToken(valid + 'a'.repeat(512))).toBe(false);
});
it('rejects a token forged with the old hardcoded dev fallback secret', async () => {
const { verifyAdminToken } = await loadAuth();
// The exact key an attacker could derive from the public source before the fix.

View File

@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { albumMetaDetail } from '../albumMeta';
import type { ImmichAlbum, ImmichAsset, ImmichExifInfo } from '../immich';
function asset(fileCreatedAt: string, exif?: Partial<ImmichExifInfo>): ImmichAsset {
return {
id: fileCreatedAt,
type: 'IMAGE',
originalFileName: 'a.jpg',
originalMimeType: 'image/jpeg',
thumbhash: null,
fileCreatedAt,
isTrashed: false,
...(exif ? { exifInfo: exif as ImmichExifInfo } : {}),
};
}
function album(assets: ImmichAsset[]): ImmichAlbum {
return {
id: 'album',
slug: 'album',
albumName: 'Album',
description: '',
albumThumbnailAssetId: null,
assetCount: assets.length,
assets,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
order: 'asc',
};
}
/**
* Both values used to come from the first asset that happened to carry EXIF, so
* a mixed album advertised whichever camera sorted first — and changing the
* album's sort mode changed the claim (#509).
*/
describe('albumMetaDetail', () => {
it('names the camera when the whole album agrees', () => {
const a = album([
asset('2026-05-01T10:00:00.000Z', { model: 'X-T5', lensModel: '35mm F1.4' }),
asset('2026-05-02T10:00:00.000Z', { model: 'X-T5', lensModel: '35mm F1.4' }),
]);
expect(albumMetaDetail(a, true).gear).toBe('X-T5 · 35mm F1.4');
});
it('says nothing about gear when the album mixes bodies', () => {
const a = album([
asset('2026-05-01T10:00:00.000Z', { model: 'X-T5' }),
asset('2026-05-02T10:00:00.000Z', { model: 'M11' }),
]);
expect(albumMetaDetail(a, true).gear).toBeUndefined();
});
it('does not change its claim when the album order changes', () => {
const shot = [
asset('2026-05-01T10:00:00.000Z', { model: 'X-T5' }),
asset('2026-05-02T10:00:00.000Z', { model: 'M11' }),
];
expect(albumMetaDetail(album(shot), true)).toEqual(
albumMetaDetail(album([...shot].reverse()), true),
);
});
it('keeps a shared camera even when only some photos carry EXIF', () => {
const a = album([
asset('2026-05-01T10:00:00.000Z', { model: 'X-T5' }),
asset('2026-05-02T10:00:00.000Z'),
]);
expect(albumMetaDetail(a, true).gear).toBe('X-T5');
});
it('omits the gear entirely when the camera group is off', () => {
const a = album([asset('2026-05-01T10:00:00.000Z', { model: 'X-T5' })]);
expect(albumMetaDetail(a, false).gear).toBeUndefined();
expect(albumMetaDetail(a, false).date).toBe('05/2026');
});
it('shows one month when the album was shot in one month', () => {
const a = album([asset('2026-05-01T10:00:00.000Z'), asset('2026-05-28T10:00:00.000Z')]);
expect(albumMetaDetail(a, true).date).toBe('05/2026');
});
it('spans a range when the album does', () => {
const a = album([asset('2026-05-01T10:00:00.000Z'), asset('2025-11-02T10:00:00.000Z')]);
expect(albumMetaDetail(a, true).date).toBe('11/2025 – 05/2026');
});
it('prefers the capture time over the upload time', () => {
const a = album([
asset('2026-08-01T10:00:00.000Z', { dateTimeOriginal: '2019-03-04T10:00:00.000Z' }),
]);
expect(albumMetaDetail(a, true).date).toBe('03/2019');
});
it('returns nothing for an empty album', () => {
expect(albumMetaDetail(album([]), true)).toEqual({});
});
});

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { resolveColorMode, COLOR_MODES } from '../config/schema';
/**
* `mode:` decides what a first-time visitor lands on. The toggle used to
* hardcode dark, so an operator who preferred light had no way to give their
* visitors anything else (#512).
*/
describe('resolveColorMode', () => {
it('defaults to dark, as the site always has', () => {
expect(resolveColorMode(undefined)).toBe('dark');
});
it('accepts each supported mode', () => {
for (const mode of COLOR_MODES) {
expect(resolveColorMode(mode)).toBe(mode);
}
});
/** A typo must not silently produce an invalid data-theme on <html>. */
it('falls back to dark on anything else', () => {
expect(resolveColorMode('Light')).toBe('dark');
expect(resolveColorMode('system')).toBe('dark');
expect(resolveColorMode('')).toBe('dark');
});
});

View File

@@ -97,16 +97,20 @@ describe('gallery.yaml per-album heroImage', () => {
expect(call?.[1]).toContain(ALBUM_ID);
});
// The substitution the wiring above depends on: validateUuid never throws, so
// a bad ID degrades to a 404 image rather than taking the whole page down.
it('real validateUuid warns and substitutes rather than throwing', async () => {
/**
* validateUuid never throws — a bad ID must not take the page down. It used to
* substitute the all-zeros UUID, which then travelled on as a real asset ID and
* came back 400 from Immich, while the log claimed it had been ignored (#517).
* Null lets the caller drop the entry, which is what "ignored" should mean.
*/
it('real validateUuid returns null and names the context', async () => {
const actual =
await vi.importActual<typeof import('@/lib/config/parser')>('@/lib/config/parser');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = actual.validateUuid('not-a-uuid', 'gallery.yaml heroImage');
expect(result).toBe('00000000-0000-0000-0000-000000000000');
expect(result).toBeNull();
expect(warn).toHaveBeenCalled();
expect(String(warn.mock.calls[0][0])).toContain('gallery.yaml heroImage');
warn.mockRestore();

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
// Imported from the schema module directly: it is client-safe (no `fs`), and
// going through '@/lib/config' would pull the filesystem loader into the test.
import {
buildCoverGridVars,
COVER_GRID_COLUMNS_MAX,
COVER_GRID_GAP_MAX,
} from '@/lib/config/schema';
describe('buildCoverGridVars', () => {
it('falls back to the site-wide column count', () => {
expect(buildCoverGridVars(undefined, 3)).toEqual({
'--subpage-columns': 3,
'--subpage-columns-tablet': 2,
});
});
it('lets a per-subpage column count win over the site-wide one', () => {
expect(buildCoverGridVars({ columns: 4 }, 3)['--subpage-columns']).toBe(4);
});
it('clamps a hand-edited column count into range', () => {
// `repeat(0, 1fr)` would collapse the grid, `repeat(99, 1fr)` shred it.
expect(buildCoverGridVars({ columns: 0 }, 3)['--subpage-columns']).toBe(1);
expect(buildCoverGridVars({ columns: 99 }, 3)['--subpage-columns']).toBe(
COVER_GRID_COLUMNS_MAX,
);
});
it('falls back to the minimum for a non-numeric column count', () => {
// YAML happily yields NaN for `columns: none`, and repeat(NaN, 1fr) drops
// the whole declaration.
expect(buildCoverGridVars({ columns: Number.NaN }, 3)['--subpage-columns']).toBe(1);
});
it('never puts more than two covers per row on tablet widths', () => {
expect(buildCoverGridVars({ columns: 1 }, 3)['--subpage-columns-tablet']).toBe(1);
expect(buildCoverGridVars({ columns: 2 }, 3)['--subpage-columns-tablet']).toBe(2);
expect(buildCoverGridVars({ columns: 6 }, 3)['--subpage-columns-tablet']).toBe(2);
});
it('omits the gap unless the subpage sets one', () => {
// Without the variable the theme preset's own --subpage-gap stands, which
// is the whole point of not letting the global grid.gap through.
expect(buildCoverGridVars({ columns: 4 }, 3)).not.toHaveProperty('--subpage-gap');
expect(buildCoverGridVars(undefined, 3)).not.toHaveProperty('--subpage-gap');
});
it('emits an explicit gap, including zero', () => {
expect(buildCoverGridVars({ gap: 24 }, 3)['--subpage-gap']).toBe('24px');
// 0 is a deliberate choice (flush covers), not an absent value.
expect(buildCoverGridVars({ gap: 0 }, 3)['--subpage-gap']).toBe('0px');
});
it('clamps the gap on both ends', () => {
expect(buildCoverGridVars({ gap: -10 }, 3)['--subpage-gap']).toBe('0px');
expect(buildCoverGridVars({ gap: 5000 }, 3)['--subpage-gap']).toBe(`${COVER_GRID_GAP_MAX}px`);
});
});

View File

@@ -0,0 +1,218 @@
import { describe, it, expect } from 'vitest';
import {
checkAuthSecret,
checkProxyHops,
countForwardedHops,
checkAlbumIds,
checkAlbumsShared,
checkPasswords,
checkWritable,
checkImmichCalls,
worstLevel,
} from '../admin/doctor';
describe('checkAuthSecret', () => {
it('accepts a long secret', () => {
expect(checkAuthSecret('a'.repeat(64)).level).toBe('ok');
});
it('warns about a short one', () => {
const f = checkAuthSecret('short');
expect(f.level).toBe('warn');
expect(f.title).toContain('5 characters');
});
it('errors when absent', () => {
expect(checkAuthSecret(undefined).level).toBe('error');
});
/** The report is shown in a browser and pasted into issues. */
it('never repeats the secret itself', () => {
const secret = 'super-secret-value-nobody-should-see';
const f = checkAuthSecret(secret);
expect(JSON.stringify(f)).not.toContain(secret);
});
});
describe('countForwardedHops', () => {
it('counts the entries of a chain', () => {
expect(countForwardedHops('203.0.113.1, 10.0.0.1')).toBe(2);
expect(countForwardedHops('203.0.113.1')).toBe(1);
});
it('treats an absent or empty header as no proxy', () => {
expect(countForwardedHops(null)).toBe(0);
expect(countForwardedHops('')).toBe(0);
expect(countForwardedHops(' , ')).toBe(0);
});
});
/**
* Both directions fail silently today: too high and the lookup runs off the end
* of the chain, too low and the IP comes from a header the client writes.
*/
describe('checkProxyHops', () => {
it('is happy when the value matches what arrived', () => {
expect(checkProxyHops(1, 1, true).level).toBe('ok');
expect(checkProxyHops(0, 0, false).level).toBe('ok');
});
it('warns when a chain arrived but no hops are configured', () => {
const f = checkProxyHops(0, 2, true);
expect(f.level).toBe('warn');
expect(f.detail).toContain('spoofing');
});
it('warns when more hops are configured than arrived', () => {
expect(checkProxyHops(3, 2, true).level).toBe('warn');
});
/** An admin on the LAN legitimately sees no chain — say so, do not accuse. */
it('explains the direct-admin case instead of calling it broken', () => {
const f = checkProxyHops(1, 0, false);
expect(f.level).toBe('warn');
expect(f.detail).toContain('directly');
});
/**
* Next fills X-Forwarded-For with the socket address when a request arrives
* without one, so a lone entry is what a *direct* request looks like. Warning
* on that would fire on every deployment that has no proxy at all.
*/
it('does not mistake the header Next writes itself for a proxy', () => {
const f = checkProxyHops(0, 1, false);
expect(f.level).toBe('ok');
expect(f.title).toContain('No reverse proxy');
});
it('does trust a lone entry when a real proxy header backs it up', () => {
const f = checkProxyHops(0, 1, true);
expect(f.level).toBe('warn');
expect(f.detail).toContain('spoofing');
});
it('accepts one configured hop against one corroborated entry', () => {
expect(checkProxyHops(1, 1, true).level).toBe('ok');
});
});
describe('checkAlbumIds', () => {
const known = [
{ id: 'a', albumName: 'Japan' },
{ id: 'b', albumName: 'Poland' },
];
it('passes when every configured album resolves', () => {
expect(checkAlbumIds(['a', 'b'], known).level).toBe('ok');
});
it('names the ids Immich does not know', () => {
const f = checkAlbumIds(['a', 'ghost'], known);
expect(f.level).toBe('error');
expect(f.detail).toContain('ghost');
});
it('flags an empty gallery', () => {
expect(checkAlbumIds([], known).level).toBe('warn');
});
});
/** Never an error: publishing an unshared album has always worked (#515). */
describe('checkAlbumsShared', () => {
const known = [
{ id: 'a', albumName: 'Japan', shared: true },
{ id: 'b', albumName: 'Private trip', shared: false },
];
it('is quiet when everything published is shared', () => {
expect(checkAlbumsShared(['a'], known).level).toBe('ok');
});
it('warns and names the unshared album', () => {
const f = checkAlbumsShared(['a', 'b'], known);
expect(f.level).toBe('warn');
expect(f.detail).toContain('Private trip');
});
/** An older Immich may not report the flag; silence beats a false alarm. */
it('says nothing when the flag is absent', () => {
expect(checkAlbumsShared(['x'], [{ id: 'x', albumName: 'Unknown' }]).level).toBe('ok');
});
});
describe('checkPasswords', () => {
it('passes when everything is hashed', () => {
expect(checkPasswords([{ label: 'Site', value: 'scrypt:aa:bb' }]).level).toBe('ok');
});
it('warns about plaintext and names where it lives', () => {
const f = checkPasswords([
{ label: 'Site', value: 'scrypt:aa:bb' },
{ label: 'Album japan-2024', value: 'hunter2' },
]);
expect(f.level).toBe('warn');
expect(f.detail).toContain('Album japan-2024');
});
/** Bcrypt cannot be verified at all any more — that is broken, not sloppy. */
it('errors on a leftover bcrypt hash', () => {
expect(checkPasswords([{ label: 'Site', value: '$2b$10$abcdefghijklmno' }]).level).toBe(
'error',
);
});
it('never repeats a password', () => {
const f = checkPasswords([{ label: 'Album x', value: 'hunter2' }]);
expect(JSON.stringify(f)).not.toContain('hunter2');
});
});
describe('checkWritable', () => {
it('passes when nothing is blocked', () => {
expect(checkWritable([]).level).toBe('ok');
});
it('errors and names the paths', () => {
const f = checkWritable(['content/', 'content/.backups']);
expect(f.level).toBe('error');
expect(f.detail).toContain('content/.backups');
});
});
describe('checkImmichCalls', () => {
it('passes when every call came back', () => {
expect(
checkImmichCalls([
{ endpoint: '/server/ping', ok: true },
{ endpoint: '/albums', ok: true },
]).level,
).toBe('ok');
});
it('names the failing endpoint', () => {
const f = checkImmichCalls([
{ endpoint: '/server/ping', ok: true },
{ endpoint: '/albums', ok: false },
]);
expect(f.level).toBe('error');
expect(f.detail).toContain('/albums');
});
});
describe('worstLevel', () => {
it('reports the most severe finding', () => {
expect(worstLevel([{ id: 'a', level: 'ok', title: '', detail: '' }])).toBe('ok');
expect(
worstLevel([
{ id: 'a', level: 'ok', title: '', detail: '' },
{ id: 'b', level: 'warn', title: '', detail: '' },
]),
).toBe('warn');
expect(
worstLevel([
{ id: 'a', level: 'warn', title: '', detail: '' },
{ id: 'b', level: 'error', title: '', detail: '' },
]),
).toBe('error');
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import yaml from 'js-yaml';
import { deriveGallery } from '../config';
import type { GalleryYaml } from '../config';
/**
* The shipped .example files are the ones README, CONTRIBUTING and the setup
* screen all tell a new user to copy. `gallery.yaml.example` carried two
* `subpages:` keys, so js-yaml refused it and a brand-new installation answered
* HTTP 500 on every route — the first thing a user saw was a broken site.
*/
const CONTENT = path.join(process.cwd(), 'content');
function load(file: string): unknown {
return yaml.load(fs.readFileSync(path.join(CONTENT, file), 'utf8'));
}
describe('shipped example files', () => {
it('gallery.yaml.example is valid YAML', () => {
expect(() => load('gallery.yaml.example')).not.toThrow();
});
it('settings.yaml.example is valid YAML', () => {
expect(() => load('settings.yaml.example')).not.toThrow();
});
/** Parsing is not enough — it has to survive the config pipeline too. */
it('gallery.yaml.example produces a usable gallery structure', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const derived = deriveGallery(load('gallery.yaml.example') as GalleryYaml);
expect(derived.subpages.length).toBeGreaterThan(0);
// Every subpage needs a slug to be reachable at all.
for (const sp of derived.subpages) {
expect(sp.slug).toBeTruthy();
}
/*
* No albums, on purpose: the example's IDs are placeholders like
* "album-uuid-3", which are not UUIDs and are therefore dropped with a
* named warning each (#517). A copied example gives a site with page
* structure and no photos, which is honest — before, the placeholders
* became the all-zeros UUID and produced an error page.
*/
expect(derived.albums).toEqual([]);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { formatCamera } from '../exif';
/**
* The lightbox printed make and model unconditionally, so a Leica read
* "LEICA CAMERA AG LEICA Q3" — long enough to run into its own label (#514).
*/
describe('formatCamera', () => {
it('drops a maker the model already names', () => {
expect(formatCamera('LEICA CAMERA AG', 'LEICA Q3')).toBe('LEICA Q3');
expect(formatCamera('NIKON CORPORATION', 'NIKON Z 6')).toBe('NIKON Z 6');
expect(formatCamera('Canon', 'Canon EOS R6')).toBe('Canon EOS R6');
});
it('keeps a maker the model does not name', () => {
expect(formatCamera('SONY', 'ILCE-7M4')).toBe('SONY ILCE-7M4');
expect(formatCamera('FUJIFILM', 'X-T5')).toBe('FUJIFILM X-T5');
});
/** The model's own separators count as word breaks. */
it('matches a brand across a hyphen', () => {
expect(formatCamera('OM Digital Solutions', 'OM-1')).toBe('OM-1');
expect(formatCamera('OM Digital Solutions', 'OM1')).toBe('OM1');
});
/** A short brand must not match inside an unrelated word. */
it('does not treat a substring as the brand', () => {
expect(formatCamera('OM Digital Solutions', 'COMPACT 3')).toBe(
'OM Digital Solutions COMPACT 3',
);
});
it('copes with either side missing', () => {
expect(formatCamera('SONY', undefined)).toBe('SONY');
expect(formatCamera(null, 'X-T5')).toBe('X-T5');
expect(formatCamera(' ', ' ')).toBe('');
});
});

View File

@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { resolveExifDisplay, hasExifPanelContent } from '../config/schema';
describe('resolveExifDisplay', () => {
it('shows everything when nothing is configured', () => {
expect(resolveExifDisplay()).toEqual({
camera: true,
settings: true,
location: true,
caption: true,
onHover: true,
});
});
/**
* `exifOnHover: false` meant "no technical EXIF anywhere" long before the
* groups existed. Existing configs must keep that meaning.
*/
it('keeps the old exifOnHover switch meaning what it did', () => {
expect(resolveExifDisplay(undefined, false)).toMatchObject({
camera: false,
settings: false,
location: false,
onHover: false,
});
});
/**
* The description was served regardless of that switch, so a site that only
* ever set `exifOnHover: false` must not lose its captions on upgrade — it
* never had them switched off (#506).
*/
it('does not let the old switch turn captions off', () => {
expect(resolveExifDisplay(undefined, false).caption).toBe(true);
});
it('lets an explicit group win over the old switch', () => {
const exif = resolveExifDisplay({ camera: true, caption: false }, false);
expect(exif.camera).toBe(true);
expect(exif.caption).toBe(false);
// Unmentioned groups still follow the switch.
expect(exif.location).toBe(false);
// …and the switch still decides the hover overlay.
expect(exif.onHover).toBe(false);
});
it('can hide only the location and the caption', () => {
expect(resolveExifDisplay({ location: false, caption: false })).toEqual({
camera: true,
settings: true,
location: false,
caption: false,
onHover: true,
});
});
});
describe('hasExifPanelContent', () => {
it('is false only when every group is off', () => {
const off = resolveExifDisplay({
camera: false,
settings: false,
location: false,
caption: false,
});
expect(hasExifPanelContent(off)).toBe(false);
});
it('stays true while one group survives', () => {
const captionOnly = resolveExifDisplay({
camera: false,
settings: false,
location: false,
caption: true,
});
expect(hasExifPanelContent(captionOnly)).toBe(true);
});
/** The hover overlay is a separate switch and must not keep the panel alive. */
it('ignores the hover overlay switch', () => {
const hoverOnly = resolveExifDisplay(
{ camera: false, settings: false, location: false, caption: false },
true,
);
expect(hoverOnly.onHover).toBe(true);
expect(hasExifPanelContent(hoverOnly)).toBe(false);
});
});

110
lib/__tests__/i18n.test.ts Normal file
View File

@@ -0,0 +1,110 @@
/**
* i18n — locale resolution and dictionary parity.
*
* The parity walk is the point: `de.ts` is typed as `Dictionary`, so a *missing*
* key is already a compile error. What the type cannot catch is a key that was
* copied over but never translated, or a plural function that ignores its
* argument — both of which ship an English string on a German site.
*/
import { describe, it, expect } from 'vitest';
import { resolveLocale, getDictionary, dictionaryFor, SUPPORTED_LOCALES } from '../i18n';
import { en } from '../i18n/locales/en';
import { de } from '../i18n/locales/de';
describe('resolveLocale', () => {
it('accepts the supported locales', () => {
expect(resolveLocale('en')).toBe('en');
expect(resolveLocale('de')).toBe('de');
});
it('drops the region subtag', () => {
expect(resolveLocale('de-DE')).toBe('de');
expect(resolveLocale('de-AT')).toBe('de');
expect(resolveLocale('en_GB')).toBe('en');
});
it('is case- and whitespace-insensitive', () => {
expect(resolveLocale(' DE ')).toBe('de');
expect(resolveLocale('De-Ch')).toBe('de');
});
it('falls back to English for languages without a dictionary', () => {
expect(resolveLocale('fr')).toBe('en');
expect(resolveLocale('ja-JP')).toBe('en');
expect(resolveLocale('')).toBe('en');
expect(resolveLocale(undefined)).toBe('en');
expect(resolveLocale(null)).toBe('en');
});
});
describe('getDictionary', () => {
it('returns a dictionary for every supported locale', () => {
for (const locale of SUPPORTED_LOCALES) {
expect(getDictionary(locale)).toBeDefined();
}
});
it('dictionaryFor resolves and looks up in one step', () => {
expect(dictionaryFor('de-DE')).toBe(de);
expect(dictionaryFor('fr')).toBe(en);
});
});
/** Every leaf of a dictionary, as `['nav.home', 'Home']` pairs. */
function leaves(obj: object, prefix = ''): [string, unknown][] {
return Object.entries(obj).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key;
return value !== null && typeof value === 'object'
? leaves(value, path)
: ([[path, value]] as [string, unknown][]);
});
}
/** Call a dictionary function with plausible arguments for its arity. */
function invoke(fn: (...args: never[]) => string): string {
const args = Array.from({ length: fn.length }, (_, i) => (i === 0 ? 2 : `arg${i}`));
return (fn as (...a: unknown[]) => string)(...args);
}
describe('dictionary parity', () => {
const enLeaves = leaves(en);
const deLeaves = new Map(leaves(de));
it('German covers every English key', () => {
expect([...deLeaves.keys()].sort()).toEqual(enLeaves.map(([k]) => k).sort());
});
it('matches value kinds — a string never stands in for an interpolator', () => {
for (const [path, value] of enLeaves) {
expect(typeof deLeaves.get(path), path).toBe(typeof value);
}
});
it('leaves no English string untranslated', () => {
// Proper nouns and units read the same in both languages; `coverAria` is
// pure interpolation with no words of its own.
const shared = new Set(['nav.journal', 'journal.title', 'lightbox.iso', 'subpage.coverAria']);
const identical = enLeaves
.filter(([path]) => !shared.has(path))
.filter(([path, value]) => {
const other = deLeaves.get(path);
if (typeof value === 'function') {
return invoke(other as (...a: never[]) => string) === invoke(value as never);
}
return other === value;
})
.map(([path]) => path);
expect(identical).toEqual([]);
});
it('plural helpers actually vary on their count', () => {
for (const dict of [en, de]) {
expect(dict.common.photos(1)).not.toBe(dict.common.photos(2));
expect(dict.common.albums(1)).not.toBe(dict.common.albums(2));
expect(dict.common.collections(1)).not.toBe(dict.common.collections(2));
}
});
});

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as config from '../config';
import { cache } from '../cache';
/**
* A deployment with credentials in the environment but no content/gallery.yaml
* is `needsSetup`, yet Immich is perfectly reachable. The client used to gate
* every request on that flag, so it returned null before the fetch — the admin
* panel reported "Immich disconnected" and nothing at all was logged (#507).
*/
const state = {
apiUrl: 'http://immich.test/api',
apiKey: 'test-key',
needsSetup: true,
needsCredentials: false,
needsGallery: true,
albums: [] as string[],
};
vi.mock('../config', async () => {
const actual = await vi.importActual<typeof config>('../config');
return {
...actual,
getConfig: () => ({
immich: { apiUrl: state.apiUrl, apiKey: state.apiKey },
authSecret: 'test-auth-secret-32-chars-long-min',
albums: state.albums,
standaloneAlbums: [],
subpages: [],
albumOverrides: {},
albumDescriptions: {},
albumSortModes: {},
albumManualOrders: {},
cacheTtl: 60_000,
staleMaxAge: 86_400_000,
immichTimeoutMs: 15000,
needsSetup: state.needsSetup,
needsCredentials: state.needsCredentials,
needsGallery: state.needsGallery,
}),
};
});
global.fetch = vi.fn();
const { immich } = await import('../immich');
describe('ImmichClient — install state', () => {
const mockFetch = global.fetch as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
state.apiUrl = 'http://immich.test/api';
state.apiKey = 'test-key';
state.needsCredentials = false;
state.needsSetup = true;
state.needsGallery = true;
state.albums = [];
cache.clear();
});
it('pings Immich even without a gallery.yaml', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => ({ res: 'pong' }),
});
await expect(immich.ping()).resolves.toBe(true);
expect(mockFetch).toHaveBeenCalledWith('http://immich.test/api/server/ping', expect.anything());
});
it('reports an unreachable Immich as disconnected rather than throwing', async () => {
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
await expect(immich.ping()).resolves.toBe(false);
});
/**
* The setup config carries an empty allowlist, so the filtered list is [] no
* matter what Immich returned. Caching that made the gallery look empty until
* the server restarted, because the wizard's invalidateAll() runs in another
* bundle's module instance and never reached this cache.
*/
it('does not cache the empty album list while setup is unfinished', async () => {
const album = {
id: 'album-1',
albumName: 'Japan',
description: '',
albumThumbnailAssetId: null,
assetCount: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
};
const respond = () => ({
ok: true,
headers: { get: () => 'application/json' },
json: async () => [album],
});
mockFetch.mockResolvedValueOnce(respond());
await expect(immich.getAlbums()).resolves.toEqual([]);
// The wizard finishes: credentials unchanged, but now there is a gallery.
state.needsSetup = false;
state.needsGallery = false;
state.albums = ['album-1'];
mockFetch.mockResolvedValueOnce(respond());
const after = await immich.getAlbums();
expect(after.map((a) => a.id)).toEqual(['album-1']);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('makes no request at all when credentials are absent', async () => {
state.apiUrl = '';
state.apiKey = '';
state.needsCredentials = true;
await expect(immich.ping()).resolves.toBe(false);
expect(mockFetch).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import yaml from 'js-yaml';
// A getter, not a value: the mock factory runs once, but each test installs
// into a fresh temp directory.
vi.mock('@/lib/env', () => ({
env: {
get INSTALL_CONTENT_DIR() {
return process.env.INSTALL_CONTENT_DIR;
},
},
}));
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-install-'));
process.env.INSTALL_CONTENT_DIR = dir;
vi.resetModules();
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
delete process.env.INSTALL_CONTENT_DIR;
});
async function install(input: Record<string, unknown>) {
const { completeInstall } = await import('@/lib/install');
await completeInstall({
apiUrl: 'http://immich.test',
apiKey: 'key',
theme: 'studio-modern',
...input,
} as Parameters<typeof completeInstall>[0]);
return yaml.load(fs.readFileSync(path.join(dir, 'gallery.yaml'), 'utf8')) as {
hero: string[];
albums: string[];
};
}
/**
* The wizard always wrote `hero: []`, so the first thing a finished portfolio
* showed was a list of album names and no photograph (#518).
*/
describe('completeInstall', () => {
it('writes the seeded hero assets', async () => {
const gallery = await install({
albums: ['11111111-1111-1111-1111-111111111111'],
hero: ['22222222-2222-2222-2222-222222222222'],
});
expect(gallery.hero).toEqual(['22222222-2222-2222-2222-222222222222']);
expect(gallery.albums).toEqual(['11111111-1111-1111-1111-111111111111']);
});
/** Seeding is a nicety; an install without it is still a valid install. */
it('writes an empty hero when none could be found', async () => {
const gallery = await install({ albums: [], hero: [] });
expect(gallery.hero).toEqual([]);
});
it('tolerates the field being omitted entirely', async () => {
const gallery = await install({ albums: [] });
expect(gallery.hero).toEqual([]);
});
});

View File

@@ -1,7 +1,22 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
/*
* The gate is mocked rather than driven through a real config: proxy() now
* consults it on every request, and reading the repository's own
* content/settings.yaml would make these tests depend on how the working copy
* happens to be configured. lib/__tests__/site-password.test.ts covers the
* verification itself.
*/
vi.mock('@/lib/auth', () => ({
isSiteUnlocked: vi.fn(() => true),
}));
import { isSiteUnlocked } from '@/lib/auth';
import { proxy, config } from '@/proxy';
const mockUnlocked = isSiteUnlocked as unknown as ReturnType<typeof vi.fn>;
// Next.js 16 renamed the "middleware" file convention to "proxy": the file must
// be proxy.ts and must export proxy(), not middleware(). A silent regression here
// (wrong filename, wrong export name) means Next.js never invokes this code and
@@ -9,6 +24,10 @@ import { proxy, config } from '@/proxy';
describe('proxy', () => {
const run = (pathname = '/') => proxy(new NextRequest(`https://example.com${pathname}`));
beforeEach(() => {
mockUnlocked.mockReturnValue(true);
});
it('is exported under the name Next.js 16 expects', () => {
expect(typeof proxy).toBe('function');
});
@@ -63,9 +82,102 @@ describe('proxy', () => {
});
it('keeps a matcher that excludes api and static assets but covers /admin', () => {
const source = config.matcher[0].source;
const source = config.matcher[0];
expect(source).toContain('api');
expect(source).toContain('_next/static');
expect(source).not.toContain('admin');
});
/**
* 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
* back to the setup screen. Unhandled, one typo in gallery.yaml became a bare
* 500 on every route (#516).
*/
it('serves the page instead of a 500 when the config cannot be read', () => {
mockUnlocked.mockImplementation(() => {
throw new Error('duplicated mapping key');
});
const res = proxy(new NextRequest('https://example.com/japan'));
// Not a rewrite to the gate, and not a throw: the page renders and shows
// the setup screen, which is the thing that can explain the fault.
expect(res.headers.get('x-middleware-rewrite')).toBeNull();
expect(res.headers.get('Content-Security-Policy')).toBeTruthy();
});
it('still gates a locked site when the config is fine', () => {
mockUnlocked.mockReturnValue(false);
const res = proxy(new NextRequest('https://example.com/japan'));
expect(res.headers.get('x-middleware-rewrite')).toContain('/gate');
});
it('no longer lets the matcher skip prefetches', () => {
/*
* The exclusion used to live in the matcher. It moved into proxy() when the
* site gate was added: a matcher that skips prefetches skips the gate with
* them, and a prefetch asks for the same RSC payload as a navigation — so
* one request header would have walked straight past a locked site.
*/
expect(JSON.stringify(config.matcher)).not.toContain('next-router-prefetch');
});
it('still leaves prefetches without a nonce or a policy', () => {
const prefetch = proxy(
new NextRequest('https://example.com/', { headers: { 'next-router-prefetch': '1' } }),
);
expect(prefetch.headers.get('Content-Security-Policy')).toBeNull();
expect(prefetch.headers.get('x-middleware-request-x-nonce')).toBeNull();
const purpose = proxy(
new NextRequest('https://example.com/', { headers: { purpose: 'prefetch' } }),
);
expect(purpose.headers.get('Content-Security-Policy')).toBeNull();
});
describe('site-wide gate', () => {
/** The header a rewrite response carries, holding the rewritten URL. */
const rewrittenTo = (res: Response) => res.headers.get('x-middleware-rewrite');
it('rewrites a locked-out visitor to the gate instead of rendering the page', () => {
mockUnlocked.mockReturnValue(false);
// A redirect would lose the requested URL; a rewrite keeps it, so
// unlocking lands the visitor where they were going.
expect(rewrittenTo(run('/japan/osaka-2023'))).toContain('/gate');
});
it('gates a prefetch too', () => {
mockUnlocked.mockReturnValue(false);
const res = proxy(
new NextRequest('https://example.com/japan', {
headers: { 'next-router-prefetch': '1' },
}),
);
// A prefetch asks for the same payload as a navigation. If the gate ran
// after the prefetch shortcut, one header would walk straight past it.
expect(rewrittenTo(res)).toContain('/gate');
});
it('leaves /admin and /install reachable', () => {
mockUnlocked.mockReturnValue(false);
// /admin owns its own password and is where the site password is set;
// /install is what a fresh deployment needs before it can have one.
expect(rewrittenTo(run('/admin'))).toBeNull();
expect(rewrittenTo(run('/admin/settings/general'))).toBeNull();
expect(rewrittenTo(run('/install'))).toBeNull();
});
it('does not rewrite the gate to itself', () => {
mockUnlocked.mockReturnValue(false);
expect(rewrittenTo(run('/gate'))).toBeNull();
});
it('stays out of the way when the site is unlocked', () => {
expect(rewrittenTo(run('/japan/osaka-2023'))).toBeNull();
expect(run().headers.get('Content-Security-Policy')).toBeTruthy();
});
});
});

View File

@@ -1,9 +1,19 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { NextRequest } from 'next/server';
/*
* getClientIp() reads the hop count straight from the environment, not through
* getConfig(): the config resolves AUTH_SECRET, which throws in production when
* none is set — and that took down the install wizard's own API on a container
* started with no environment at all (#519).
*/
let mockHops = 0;
vi.mock('@/lib/config', () => ({
getConfig: () => ({ trustedProxyHops: mockHops }),
vi.mock('@/lib/env', () => ({
env: {
get TRUSTED_PROXY_HOPS() {
return mockHops;
},
},
}));
import {

View File

@@ -0,0 +1,191 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* The site-wide gate rides on the same HMAC machinery as subpage, album and
* journal passwords — the point of these tests is that it really is the same
* path (signed expiry, constant-time compare, opt-in when unset) and not a
* second, weaker scheme bolted on beside it.
*/
const config = {
authSecret: 'test-secret-that-is-at-least-32-chars-long',
sitePassword: '',
subpages: [] as unknown[],
albumPasswords: {} as Record<string, string>,
};
vi.mock('@/lib/config', () => ({
getConfig: () => config,
}));
import {
authenticate,
isAuthenticated,
isSiteLocked,
isSiteUnlocked,
siteLockResponse,
SITE_AUTH_COOKIE,
SITE_AUTH_KEY,
} from '@/lib/auth';
/** Pull the cookie value back out of a Set-Cookie header. */
function cookieValue(setCookie: string): string {
return setCookie.slice(setCookie.indexOf('=') + 1, setCookie.indexOf(';'));
}
const jar = (value?: string) => (name: string) => (name === SITE_AUTH_COOKIE ? value : undefined);
beforeEach(() => {
config.sitePassword = '';
});
describe('an open site', () => {
it('is not locked when no password is configured', () => {
expect(isSiteLocked()).toBe(false);
});
it('serves everyone, cookie or not', () => {
expect(isSiteUnlocked(jar())).toBe(true);
expect(siteLockResponse(new Request('http://localhost/api/image/x'))).toBeNull();
});
it('refuses to issue a token — there is nothing to unlock', async () => {
expect(await authenticate(SITE_AUTH_KEY, 'anything', 'site')).toBeNull();
});
});
describe('a locked site', () => {
beforeEach(() => {
config.sitePassword = 'letmein';
});
it('reports itself locked', () => {
expect(isSiteLocked()).toBe(true);
});
it('turns away a visitor with no cookie', () => {
expect(isSiteUnlocked(jar())).toBe(false);
});
it('rejects the wrong password', async () => {
expect(await authenticate(SITE_AUTH_KEY, 'wrong', 'site')).toBeNull();
});
it('issues an HttpOnly cookie for the right password', async () => {
const setCookie = await authenticate(SITE_AUTH_KEY, 'letmein', 'site');
expect(setCookie).toContain(`${SITE_AUTH_COOKIE}=`);
expect(setCookie).toContain('HttpOnly');
expect(setCookie).toContain('SameSite=Strict');
expect(setCookie).toContain('Path=/');
});
it('accepts the cookie it just issued', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
expect(isSiteUnlocked(jar(cookieValue(setCookie)))).toBe(true);
});
it('rejects a token whose expiry has been pushed out by hand', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
const [, mac] = cookieValue(setCookie).split('.');
const forged = `${Date.now() + 10 * 365 * 24 * 3600_000}.${mac}`;
expect(isSiteUnlocked(jar(forged))).toBe(false);
});
it('rejects an expired token', () => {
// Signed with a past expiry: the signature is valid, the deadline is not.
expect(isSiteUnlocked(jar(`${Date.now() - 1000}.deadbeef`))).toBe(false);
});
it('rejects garbage', () => {
expect(isSiteUnlocked(jar(''))).toBe(false);
expect(isSiteUnlocked(jar('not-a-token'))).toBe(false);
expect(isSiteUnlocked(jar('...'))).toBe(false);
});
it('does not unlock the site with another gate’s cookie', async () => {
config.subpages = [{ slug: 'private', password: 'letmein' }];
const subpageCookie = (await authenticate('private', 'letmein', 'subpage'))!;
// Same password, different key — the key is part of the signed payload.
expect(isSiteUnlocked(jar(cookieValue(subpageCookie)))).toBe(false);
config.subpages = [];
});
it('stops changing its mind once the password is rotated', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
config.sitePassword = 'something-else';
expect(isSiteUnlocked(jar(cookieValue(setCookie)))).toBe(false);
});
});
describe('siteLockResponse', () => {
beforeEach(() => {
config.sitePassword = 'letmein';
});
it('answers 401 without a cookie, and does not let it be cached', async () => {
const res = siteLockResponse(new Request('http://localhost/api/image/x'))!;
expect(res.status).toBe(401);
expect(res.headers.get('Cache-Control')).toBe('no-store');
// A WWW-Authenticate header would make the browser pop its own dialog.
expect(res.headers.get('WWW-Authenticate')).toBeNull();
});
it('reads the cookie out of the request header', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
const req = new Request('http://localhost/api/image/x', {
headers: { cookie: `theme=dark; ${SITE_AUTH_COOKIE}=${cookieValue(setCookie)}; other=1` },
});
expect(siteLockResponse(req)).toBeNull();
});
it('is not fooled by a cookie whose name merely ends the same way', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
const req = new Request('http://localhost/api/image/x', {
headers: { cookie: `not_${SITE_AUTH_COOKIE}=${cookieValue(setCookie)}` },
});
expect(siteLockResponse(req)).not.toBeNull();
});
});
describe('a subpage slugged "site"', () => {
beforeEach(() => {
config.sitePassword = 'letmein';
});
it('does not write to the site gate’s cookie', async () => {
config.subpages = [{ slug: 'site', password: 'subpage-pw' }];
const setCookie = (await authenticate('site', 'subpage-pw', 'subpage'))!;
expect(setCookie.startsWith(`${SITE_AUTH_COOKIE}=`)).toBe(false);
expect(setCookie).toContain('lb_auth_site=');
config.subpages = [];
});
it('does not unlock the site even when both passwords match', async () => {
// Same key ("site"), same password — only the domain separator in the
// signed payload keeps the two tokens apart.
config.subpages = [{ slug: 'site', password: 'letmein' }];
const subpageCookie = cookieValue((await authenticate('site', 'letmein', 'subpage'))!);
expect(isSiteUnlocked(jar(subpageCookie))).toBe(false);
config.subpages = [];
});
it('is not unlocked by the site cookie either', async () => {
config.subpages = [{ slug: 'site', password: 'letmein' }];
const siteCookie = cookieValue((await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!);
expect(isAuthenticated('site', () => siteCookie, 'subpage')).toBe(false);
config.subpages = [];
});
});
describe('the other gates are untouched', () => {
it('still protects a subpage independently of the site lock', async () => {
config.sitePassword = 'letmein';
config.subpages = [{ slug: 'private', password: 'other' }];
const siteCookie = cookieValue((await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!);
// Unlocking the site does not unlock a subpage inside it.
expect(isAuthenticated('private', () => siteCookie, 'subpage')).toBe(false);
config.subpages = [];
});
});

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { resolveWatermarkOpacity, WATERMARK_DEFAULT_OPACITY } from '../config/schema';
/**
* The lightbox used to divide `watermark.opacity` by 100 while both the docs
* and the admin slider expressed it as a fraction, so a configured 0.9 rendered
* as 0.009 — invisible (#508).
*/
describe('resolveWatermarkOpacity', () => {
it('passes a documented fraction through unchanged', () => {
expect(resolveWatermarkOpacity(0.5)).toBe(0.5);
expect(resolveWatermarkOpacity(0.9)).toBe(0.9);
});
it('reads a value above 1 as a percentage', () => {
expect(resolveWatermarkOpacity(90)).toBe(0.9);
expect(resolveWatermarkOpacity(50)).toBe(0.5);
});
it('treats 1 as fully opaque, not as one percent', () => {
expect(resolveWatermarkOpacity(1)).toBe(1);
});
it('clamps out-of-range values into 0–1', () => {
expect(resolveWatermarkOpacity(400)).toBe(1);
expect(resolveWatermarkOpacity(-2)).toBe(0);
});
it('falls back to the default when unset or not a number', () => {
expect(resolveWatermarkOpacity(undefined)).toBe(WATERMARK_DEFAULT_OPACITY);
expect(resolveWatermarkOpacity(NaN)).toBe(WATERMARK_DEFAULT_OPACITY);
});
it('keeps 0 as fully transparent rather than falling back', () => {
expect(resolveWatermarkOpacity(0)).toBe(0);
});
});