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:
@@ -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.
|
||||
|
||||
107
lib/__tests__/album-meta.test.ts
Normal file
107
lib/__tests__/album-meta.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
26
lib/__tests__/color-mode.test.ts
Normal file
26
lib/__tests__/color-mode.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
60
lib/__tests__/cover-grid.test.ts
Normal file
60
lib/__tests__/cover-grid.test.ts
Normal 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`);
|
||||
});
|
||||
});
|
||||
218
lib/__tests__/doctor.test.ts
Normal file
218
lib/__tests__/doctor.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
51
lib/__tests__/example-files.test.ts
Normal file
51
lib/__tests__/example-files.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
38
lib/__tests__/exif-camera.test.ts
Normal file
38
lib/__tests__/exif-camera.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
92
lib/__tests__/exif-display.test.ts
Normal file
92
lib/__tests__/exif-display.test.ts
Normal 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
110
lib/__tests__/i18n.test.ts
Normal 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));
|
||||
}
|
||||
});
|
||||
});
|
||||
123
lib/__tests__/immich-setup-state.test.ts
Normal file
123
lib/__tests__/immich-setup-state.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
71
lib/__tests__/install-hero.test.ts
Normal file
71
lib/__tests__/install-hero.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
191
lib/__tests__/site-password.test.ts
Normal file
191
lib/__tests__/site-password.test.ts
Normal 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 = [];
|
||||
});
|
||||
});
|
||||
37
lib/__tests__/watermark-opacity.test.ts
Normal file
37
lib/__tests__/watermark-opacity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user