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>
425 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|