298 lines
10 KiB
TypeScript
298 lines
10 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 || '';
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}));
|
||
|
|
|
||
|
|
/** 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: '',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
});
|