Files
immich-folio/lib/__tests__/install.test.ts
Ralksta a3a6319c29 release: v0.16.0
See CHANGELOG.md for the full v0.16.0 entry.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
2026-09-22 14:12:13 +02:00

425 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
// Readable env getters, like admin-auth.test.ts, so tests can toggle which
// values come from the environment vs. the wizard file.
vi.mock('@/lib/env', () => ({
env: {
get IMMICH_API_URL() {
return process.env.__T_INSTALL_URL || '';
},
get IMMICH_API_KEY() {
return process.env.__T_INSTALL_KEY || '';
},
get AUTH_SECRET() {
return process.env.__T_INSTALL_SECRET || '';
},
get ADMIN_PASSWORD() {
return process.env.__T_INSTALL_ADMIN || '';
},
get INSTALL_CONTENT_DIR() {
return process.env.INSTALL_CONTENT_DIR || '';
},
},
// Real implementation, not a stub: install.ts's own normalisation tests
// (#634) rely on this actually stripping trailing slashes.
normalizeApiUrl: (raw: string) => {
try {
return new URL(raw).toString().replace(/\/+$/, '');
} catch {
return '';
}
},
}));
/** Import fresh so the module-level install-file cache cannot leak between cases. */
async function loadInstall() {
vi.resetModules();
return import('@/lib/install');
}
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'install-test-'));
vi.stubEnv('INSTALL_CONTENT_DIR', dir);
});
afterEach(() => {
vi.unstubAllEnvs();
delete process.env.__T_INSTALL_URL;
delete process.env.__T_INSTALL_KEY;
delete process.env.__T_INSTALL_SECRET;
delete process.env.__T_INSTALL_ADMIN;
fs.rmSync(dir, { recursive: true, force: true });
});
const ALBUM_ID = '11111111-1111-1111-1111-111111111111';
describe('getInstallCredentials', () => {
it('returns the file values when the environment is empty', async () => {
const { getInstallCredentials } = await loadInstall();
fs.writeFileSync(
path.join(dir, 'install.json'),
JSON.stringify({
apiUrl: 'https://file-immich:2283',
apiKey: 'file-key',
authSecret: 'file-secret',
adminPassword: 'file-admin',
}),
);
expect(getInstallCredentials()).toEqual({
apiUrl: 'https://file-immich:2283',
apiKey: 'file-key',
authSecret: 'file-secret',
adminPassword: 'file-admin',
});
});
it('lets environment variables win over the file', async () => {
const { getInstallCredentials } = await loadInstall();
process.env.__T_INSTALL_URL = 'https://env-immich:2283';
process.env.__T_INSTALL_KEY = 'env-key';
process.env.__T_INSTALL_SECRET = 'env-secret';
process.env.__T_INSTALL_ADMIN = 'env-admin';
fs.writeFileSync(
path.join(dir, 'install.json'),
JSON.stringify({
apiUrl: 'https://file-immich:2283',
apiKey: 'file-key',
authSecret: 'file-secret',
adminPassword: 'file-admin',
}),
);
expect(getInstallCredentials()).toEqual({
apiUrl: 'https://env-immich:2283',
apiKey: 'env-key',
authSecret: 'env-secret',
adminPassword: 'env-admin',
});
});
it('returns empty strings when neither source has values', async () => {
const { getInstallCredentials } = await loadInstall();
expect(getInstallCredentials()).toEqual({
apiUrl: '',
apiKey: '',
authSecret: '',
adminPassword: '',
});
});
/**
* The environment path already strips a trailing slash (lib/env.ts); the
* file path used to store content/install.json's apiUrl completely
* unnormalised. `https://host/api/` then reached getConfig() as
* `.../api/`, which does not `endsWith('/api')`, so it became
* `.../api//api` and every request 404ed (#634). Normalising on every read
* means an install.json written before this fix heals without re-running
* the wizard.
*/
describe('heals an unnormalised URL already on disk (#634)', () => {
it.each([
['trailing slash, no /api', 'https://file-immich:2283/', 'https://file-immich:2283'],
[
'trailing slash, with /api',
'https://file-immich:2283/api/',
'https://file-immich:2283/api',
],
['no trailing slash, already clean', 'https://file-immich:2283', 'https://file-immich:2283'],
])('file path: %s', async (_name, stored, expected) => {
const { getInstallCredentials } = await loadInstall();
fs.writeFileSync(
path.join(dir, 'install.json'),
JSON.stringify({ apiUrl: stored, apiKey: 'file-key' }),
);
expect(getInstallCredentials().apiUrl).toBe(expected);
});
it.each([
['trailing slash, no /api', 'https://env-immich:2283/', 'https://env-immich:2283'],
['trailing slash, with /api', 'https://env-immich:2283/api/', 'https://env-immich:2283/api'],
])('env path: %s', async (_name, envValue, expected) => {
const { getInstallCredentials } = await loadInstall();
process.env.__T_INSTALL_URL = envValue;
expect(getInstallCredentials().apiUrl).toBe(expected);
});
});
});
describe('isInstalled', () => {
it('is false while gallery.yaml is missing', async () => {
const { isInstalled } = await loadInstall();
process.env.__T_INSTALL_URL = 'https://immich:2283';
process.env.__T_INSTALL_KEY = 'key';
expect(isInstalled()).toBe(false);
});
it('is false when credentials are missing even if gallery.yaml exists', async () => {
const { isInstalled } = await loadInstall();
fs.writeFileSync(path.join(dir, 'gallery.yaml'), 'albums: []\n');
expect(isInstalled()).toBe(false);
});
it('is true once gallery.yaml exists and credentials resolve', async () => {
const { isInstalled } = await loadInstall();
fs.writeFileSync(path.join(dir, 'gallery.yaml'), 'albums: []\n');
process.env.__T_INSTALL_URL = 'https://immich:2283';
process.env.__T_INSTALL_KEY = 'key';
expect(isInstalled()).toBe(true);
});
});
describe('completeInstall', () => {
it('writes gallery.yaml, settings.yaml and install.json', async () => {
const { completeInstall, getInstallCredentials } = await loadInstall();
await completeInstall({
apiUrl: 'https://immich.example',
apiKey: 'api-key-123',
siteTitle: 'My Portfolio',
siteSubtitle: 'A visual journal',
theme: 'noir',
albums: [ALBUM_ID],
adminPassword: 'hunter2',
});
const gallery = fs.readFileSync(path.join(dir, 'gallery.yaml'), 'utf8');
expect(gallery).toContain('albums');
expect(gallery).toContain(ALBUM_ID);
const settings = fs.readFileSync(path.join(dir, 'settings.yaml'), 'utf8');
expect(settings).toContain('My Portfolio');
expect(settings).toContain('noir');
const creds = JSON.parse(fs.readFileSync(path.join(dir, 'install.json'), 'utf8'));
expect(creds.apiUrl).toBe('https://immich.example');
expect(creds.apiKey).toBe('api-key-123');
// Stored as a hash, never as typed — an admin password is the one
// credential here a person picked and may have reused elsewhere.
expect(creds.adminPassword).not.toBe('hunter2');
expect(creds.adminPassword).toMatch(/^scrypt:[0-9a-f]+:[0-9a-f]+$/);
const { verifyScrypt } = await import('@/lib/password');
expect(await verifyScrypt('hunter2', creds.adminPassword)).toBe(true);
expect(await verifyScrypt('wrong', creds.adminPassword)).toBe(false);
// A fresh, unguessable site secret is generated.
expect(creds.authSecret).toBeTruthy();
expect(creds.authSecret.length).toBeGreaterThanOrEqual(32);
// The generated secret resolves through getInstallCredentials.
expect(getInstallCredentials().authSecret).toBe(creds.authSecret);
});
/**
* The route verifies the connection against normalizeApiBase(apiUrl),
* which strips a trailing slash, but this used to write input.apiUrl.trim()
* unchanged — so a URL that passed the ping test was stored as typed and
* every request afterwards 404ed (#634).
*/
it.each([
['trailing slash, no /api', 'https://immich.example/', 'https://immich.example'],
['trailing slash, with /api', 'https://immich.example/api/', 'https://immich.example/api'],
])('normalises the stored apiUrl: %s', async (_name, typed, expected) => {
const { completeInstall } = await loadInstall();
await completeInstall({ apiUrl: typed, apiKey: 'api-key-123' });
const creds = JSON.parse(fs.readFileSync(path.join(dir, 'install.json'), 'utf8'));
expect(creds.apiUrl).toBe(expected);
});
it('writes an empty albums list when no albums are selected', async () => {
const { completeInstall } = await loadInstall();
await completeInstall({
apiUrl: 'https://immich.example',
apiKey: 'api-key-123',
albums: [],
});
const gallery = fs.readFileSync(path.join(dir, 'gallery.yaml'), 'utf8');
expect(gallery).toContain('albums: []');
});
it('does not set an admin password when none is given', async () => {
const { completeInstall } = await loadInstall();
await completeInstall({
apiUrl: 'https://immich.example',
apiKey: 'api-key-123',
});
const creds = JSON.parse(fs.readFileSync(path.join(dir, 'install.json'), 'utf8'));
expect(creds.adminPassword).toBeUndefined();
});
it('makes isInstalled() true immediately — no restart required', async () => {
const { completeInstall, isInstalled } = await loadInstall();
expect(isInstalled()).toBe(false);
await completeInstall({
apiUrl: 'https://immich.example',
apiKey: 'api-key-123',
});
expect(isInstalled()).toBe(true);
});
/**
* isInstalled() is false whenever Immich credentials fail to resolve,
* whether or not gallery.yaml exists — a bad IMMICH_API_URL or a corrupt
* install.json is enough. Re-running the wizard after that used to replace
* every subpage, section, password, assetOrder and per-album override with
* a two-key skeleton (#627).
*/
it('does not overwrite an existing gallery.yaml', async () => {
const { completeInstall } = await loadInstall();
const galleryPath = path.join(dir, 'gallery.yaml');
const existing = 'albums:\n - existing-album\nsubpages:\n - slug: trips\n';
fs.writeFileSync(galleryPath, existing);
const result = await completeInstall({
apiUrl: 'https://immich.example',
apiKey: 'api-key-123',
albums: [ALBUM_ID],
});
expect(result.galleryWritten).toBe(false);
expect(fs.readFileSync(galleryPath, 'utf8')).toBe(existing);
});
it('does not overwrite an existing settings.yaml', async () => {
const { completeInstall } = await loadInstall();
const settingsPath = path.join(dir, 'settings.yaml');
const existing = 'title: "Existing Site"\n';
fs.writeFileSync(settingsPath, existing);
const result = await completeInstall({
apiUrl: 'https://immich.example',
apiKey: 'api-key-123',
siteTitle: 'New Title',
});
expect(result.settingsWritten).toBe(false);
expect(fs.readFileSync(settingsPath, 'utf8')).toBe(existing);
});
it('writes both files on a genuinely fresh install and reports it', async () => {
const { completeInstall } = await loadInstall();
const result = await completeInstall({
apiUrl: 'https://immich.example',
apiKey: 'api-key-123',
});
expect(result).toEqual({ galleryWritten: true, settingsWritten: true });
});
it('still writes fresh credentials into install.json when gallery.yaml is kept', async () => {
const { completeInstall, getInstallCredentials } = await loadInstall();
fs.writeFileSync(path.join(dir, 'gallery.yaml'), 'albums: []\n');
await completeInstall({
apiUrl: 'https://immich.example',
apiKey: 'fresh-key',
});
expect(getInstallCredentials().apiKey).toBe('fresh-key');
});
});
describe('normalizeApiBase', () => {
it('appends /api to a base URL', async () => {
const { normalizeApiBase } = await loadInstall();
expect(normalizeApiBase('https://photos.example.com')).toBe('https://photos.example.com/api');
});
it('strips a trailing slash', async () => {
const { normalizeApiBase } = await loadInstall();
expect(normalizeApiBase('https://photos.example.com/')).toBe('https://photos.example.com/api');
});
it('does not double-append /api', async () => {
const { normalizeApiBase } = await loadInstall();
expect(normalizeApiBase('https://photos.example.com/api')).toBe(
'https://photos.example.com/api',
);
});
it('rejects non-http(s) schemes and garbage', async () => {
const { normalizeApiBase } = await loadInstall();
expect(normalizeApiBase('ftp://photos.example.com')).toBeNull();
expect(normalizeApiBase('not a url')).toBeNull();
expect(normalizeApiBase('')).toBeNull();
});
});
describe('isInstallPath', () => {
it('matches the wizard route only', async () => {
const { isInstallPath } = await loadInstall();
expect(isInstallPath('/install')).toBe(true);
expect(isInstallPath('/install/')).toBe(true);
expect(isInstallPath('/api/install')).toBe(false);
expect(isInstallPath('/admin')).toBe(false);
expect(isInstallPath('/')).toBe(false);
expect(isInstallPath(null)).toBe(false);
});
});
describe('setup token', () => {
/**
* The reason this is a file and not a module variable: Next.js bundles the
* install page and the install route handlers separately, so each gets its
* own instance of lib/install.ts. A module-level token made /install render
* the wizard and /api/install reject that same token, inside one process —
* the wizard could not get past step 1. Two freshly imported module
* instances reproduce exactly that split.
*/
it('agrees across separate module instances', async () => {
const pageSide = await loadInstall();
const token = pageSide.getSetupToken();
const apiSide = await loadInstall(); // fresh instance, as a second bundle would be
expect(apiSide.validateSetupToken(token)).toBe(true);
});
it('persists across a restart, so the operator keeps the logged token', async () => {
const first = await loadInstall();
const token = first.getSetupToken();
const afterRestart = await loadInstall();
expect(afterRestart.getSetupToken()).toBe(token);
});
it('writes the token 0600 — it is a credential, not a config value', async () => {
const { getSetupToken } = await loadInstall();
getSetupToken();
const mode = fs.statSync(path.join(dir, '.setup-token')).mode & 0o777;
expect(mode).toBe(0o600);
});
it('rejects a wrong or missing token', async () => {
const { getSetupToken, validateSetupToken } = await loadInstall();
getSetupToken();
expect(validateSetupToken('nope')).toBe(false);
expect(validateSetupToken('')).toBe(false);
expect(validateSetupToken(null)).toBe(false);
});
// Once install is done every install route answers 403, so the token guards
// nothing — leaving it on disk would just be a stray credential.
it('is removed once the install completes', async () => {
const { getSetupToken, completeInstall } = await loadInstall();
getSetupToken();
expect(fs.existsSync(path.join(dir, '.setup-token'))).toBe(true);
await completeInstall({ apiUrl: 'http://immich.local', apiKey: 'k', albums: [] });
expect(fs.existsSync(path.join(dir, '.setup-token'))).toBe(false);
});
});