c557108eee
A deployment no longer suggests an address for the first server: every address is each account's own and is typed in the app, so the field opens blank with a placeholder. The env var, its compose passthrough, the example line and the `defaultUrl` field that carried it are all gone; the backend's config route now answers with the saved list alone.
376 lines
20 KiB
JavaScript
376 lines
20 KiB
JavaScript
// Immich proxy self-check for the API. Boots the real server against a
|
|
// throwaway DATA_DIR and a fake Immich on loopback, then walks the boundaries
|
|
// that matter: who may reach the proxy, what a key is allowed to become, and
|
|
// what the browser is allowed to learn about it.
|
|
//
|
|
// npm run test:immich (from docker/backend/)
|
|
//
|
|
// Node only — no test framework, no network beyond loopback. The fake Immich is
|
|
// deliberately strict: it refuses any request that does not carry the key it
|
|
// expects, so a route that forgets to attach one fails here rather than in the
|
|
// LIBRARY.
|
|
import { spawn } from 'node:child_process';
|
|
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
import { createServer } from 'node:http';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const PORT = Number(process.env.TEST_PORT || 3412);
|
|
const BASE = `http://127.0.0.1:${PORT}/api`;
|
|
const DATA_DIR = mkdtempSync(join(tmpdir(), 'recipescam-immich-'));
|
|
|
|
const KEY = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaabcd';
|
|
const KEY_NODL = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbb1234'; // the same key without asset.download
|
|
const SHARE_KEY = 'share-key-1234567890';
|
|
const ALBUM_A = '11111111-1111-4111-8111-111111111111';
|
|
const ALBUM_B = '22222222-2222-4222-8222-222222222222';
|
|
const assetId = (n) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
|
const THUMB = Buffer.from('RIFF0000WEBPfake-tile-bytes');
|
|
const ORIGINAL = Buffer.from('II*\0fake-original-bytes');
|
|
|
|
const asset = (n, album) => ({
|
|
id: assetId(n),
|
|
type: 'IMAGE',
|
|
originalFileName: `photo-${n}.jpg`,
|
|
fileCreatedAt: `2026-01-0${n}T10:00:00.000Z`,
|
|
width: 4000,
|
|
height: 3000,
|
|
exifInfo: { fileSizeInByte: 1000 * n },
|
|
album,
|
|
});
|
|
|
|
let pass = 0;
|
|
let fail = 0;
|
|
const check = (name, ok, detail = '') => {
|
|
if (ok) {
|
|
pass++;
|
|
console.log(`PASS ${name}`);
|
|
} else {
|
|
fail++;
|
|
console.log(`FAIL ${name}${detail ? ` :: ${detail}` : ''}`);
|
|
}
|
|
};
|
|
|
|
// ---- the fake Immich --------------------------------------------------------
|
|
// Every request is recorded, so "did the proxy attach the key, and did it ask
|
|
// for the size it promised?" is an assertion rather than a hope.
|
|
const seen = [];
|
|
function startFake() {
|
|
const server = createServer((req, res) => {
|
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
const apiKey = req.headers['x-api-key'];
|
|
const shareKey = url.searchParams.get('key');
|
|
seen.push({
|
|
path: url.pathname,
|
|
size: url.searchParams.get('size'),
|
|
apiKey: apiKey ?? null,
|
|
shareKey: shareKey ?? null,
|
|
key: apiKey ?? shareKey ?? null,
|
|
range: req.headers.range ?? null,
|
|
});
|
|
const send = (status, payload, type = 'application/json') => {
|
|
res.writeHead(status, { 'content-type': type });
|
|
res.end(Buffer.isBuffer(payload) ? payload : JSON.stringify(payload));
|
|
};
|
|
const chunks = [];
|
|
req.on('data', (c) => chunks.push(c));
|
|
req.on('end', () => {
|
|
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {};
|
|
const authed = apiKey === KEY || apiKey === KEY_NODL;
|
|
// A share link is a key too, but only Immich's asset routes read it from
|
|
// `?key=` — the album and search routes are API-key territory.
|
|
const shareAuthed = shareKey === SHARE_KEY;
|
|
|
|
if (url.pathname === '/api/server/version') return send(200, { major: 3, minor: 1, patch: 0, prerelease: null });
|
|
|
|
if (url.pathname === '/api/shared-links/me') {
|
|
if (shareKey !== SHARE_KEY) return send(401, { message: 'Authentication required' });
|
|
return send(200, {
|
|
id: 'link-1',
|
|
album: { id: ALBUM_B, albumName: 'Album chia sẻ', assetCount: 2 },
|
|
allowDownload: true,
|
|
assets: [asset(5), asset(6)],
|
|
});
|
|
}
|
|
|
|
if (url.pathname === '/api/albums') {
|
|
if (!authed) return send(401, { message: 'Authentication required' });
|
|
return send(200, [
|
|
{ id: ALBUM_A, albumName: 'Nhà', assetCount: 2, albumThumbnailAssetId: assetId(1), shared: false },
|
|
{ id: ALBUM_B, albumName: 'Du lịch', assetCount: 1, albumThumbnailAssetId: assetId(3), shared: true },
|
|
]);
|
|
}
|
|
|
|
if (url.pathname === '/api/search/metadata') {
|
|
const all = [asset(1, ALBUM_A), asset(2, ALBUM_A), asset(3, ALBUM_B)];
|
|
const filtered = Array.isArray(body.albumIds) ? all.filter((a) => body.albumIds.includes(a.album)) : all;
|
|
const size = Number(body.size ?? 100);
|
|
const page = Number(body.page ?? 1);
|
|
const items = filtered.slice((page - 1) * size, page * size);
|
|
return send(200, {
|
|
albums: { items: [], total: 0 },
|
|
assets: { items, total: filtered.length, count: items.length, nextPage: null },
|
|
});
|
|
}
|
|
|
|
if (!authed && !shareAuthed) return send(401, { message: 'Authentication required' });
|
|
|
|
const thumb = url.pathname.match(/^\/api\/assets\/([^/]+)\/thumbnail$/);
|
|
if (thumb) {
|
|
if (![1, 2, 3, 5, 6].some((n) => assetId(n) === thumb[1])) return send(404, { message: 'Not found' });
|
|
return send(200, THUMB, 'image/webp');
|
|
}
|
|
|
|
const original = url.pathname.match(/^\/api\/assets\/([^/]+)\/original$/);
|
|
if (original) {
|
|
if (apiKey === KEY_NODL) return send(403, { message: 'Missing required permission: asset.download' });
|
|
if (req.headers.range) {
|
|
res.writeHead(206, { 'content-type': 'image/jpeg', 'content-range': 'bytes 0-0/24' });
|
|
return res.end(ORIGINAL.subarray(0, 1));
|
|
}
|
|
return send(200, ORIGINAL, 'image/jpeg');
|
|
}
|
|
|
|
return send(404, { message: 'Not found' });
|
|
});
|
|
});
|
|
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port })));
|
|
}
|
|
|
|
// ---- one cookie jar per actor, as in test/security.mjs ----------------------
|
|
function actor() {
|
|
let cookie = '';
|
|
return {
|
|
get cookie() {
|
|
return cookie;
|
|
},
|
|
async req(path, init = {}) {
|
|
const headers = { ...(init.headers ?? {}) };
|
|
if (cookie) headers.cookie = cookie;
|
|
const res = await fetch(BASE + path, { ...init, headers });
|
|
const set = res.headers.getSetCookie?.() ?? [];
|
|
for (const line of set) {
|
|
const value = line.split(';')[0];
|
|
if (value.startsWith('rc_session=')) cookie = value;
|
|
}
|
|
const type = res.headers.get('content-type') ?? '';
|
|
const body = type.includes('json') ? await res.json() : Buffer.from(await res.arrayBuffer());
|
|
return { status: res.status, headers: res.headers, body };
|
|
},
|
|
post(path, payload) {
|
|
return this.req(path, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
put(path, payload) {
|
|
return this.req(path, {
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
},
|
|
async signup(email) {
|
|
const res = await this.post('/auth/signup', { email, password: 'supersecret1' });
|
|
if (res.status !== 201) throw new Error(`signup for ${email} answered ${res.status}`);
|
|
return res;
|
|
},
|
|
};
|
|
}
|
|
|
|
const fake = await startFake();
|
|
const tsx = join(ROOT, 'node_modules/.bin/tsx');
|
|
const entry = existsSync(tsx) ? [tsx, 'src/server.ts'] : ['dist/server.js'];
|
|
const server = spawn(process.execPath, entry, {
|
|
cwd: ROOT,
|
|
env: {
|
|
...process.env,
|
|
PORT: String(PORT),
|
|
DATA_DIR,
|
|
NODE_ENV: 'test',
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let serverLog = '';
|
|
server.stdout.on('data', (d) => (serverLog += d));
|
|
server.stderr.on('data', (d) => (serverLog += d));
|
|
|
|
async function waitForServer() {
|
|
for (let i = 0; i < 100; i++) {
|
|
try {
|
|
if ((await fetch(`${BASE}/health`)).ok) return true;
|
|
} catch {
|
|
/* not up yet */
|
|
}
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const url = `http://127.0.0.1:${fake.port}`;
|
|
|
|
try {
|
|
if (!(await waitForServer())) throw new Error(`server never came up:\n${serverLog}`);
|
|
const stamp = Date.now();
|
|
const user = actor();
|
|
const other = actor();
|
|
await user.signup(`immich${stamp}@test.local`);
|
|
await other.signup(`immich-other${stamp}@test.local`);
|
|
|
|
// ---- who may reach the proxy at all --------------------------------------
|
|
const anonymous = await fetch(`${BASE}/immich/config`);
|
|
check('the proxy refuses a signed-out caller', anonymous.status === 401, `got ${anonymous.status}`);
|
|
check('the proxy refuses a signed-out thumb', (await fetch(`${BASE}/immich/thumb?server=x&id=${assetId(1)}`)).status === 401);
|
|
|
|
// ---- the "check this key" probe -----------------------------------------
|
|
const noKey = await user.post('/immich/probe', { url });
|
|
check('a probe without a key is refused', noKey.status === 400 && noKey.body?.error === 'invalid', JSON.stringify(noKey.body));
|
|
const badScheme = await user.post('/immich/probe', { url: 'file:///etc', key: KEY });
|
|
check('a probe refuses a non-http address', badScheme.status === 400, JSON.stringify(badScheme.body));
|
|
const dead = await user.post('/immich/probe', { url: 'http://127.0.0.1:1', key: KEY });
|
|
check('a probe of a dead host answers unreachable', dead.body?.error === 'unreachable', JSON.stringify(dead.body));
|
|
const wrongKey = await user.post('/immich/probe', { url, key: 'nope' });
|
|
check('a probe with a wrong key answers auth', wrongKey.body?.error === 'auth', JSON.stringify(wrongKey.body));
|
|
|
|
const good = await user.post('/immich/probe', { url, key: KEY });
|
|
check('a probe reports the server version', good.body?.version === '3.1.0', JSON.stringify(good.body?.version));
|
|
check('a probe lists the albums the key sees', good.body?.albums?.length === 2, JSON.stringify(good.body?.albums));
|
|
check('a probe reports the album count', good.body?.albums?.[0]?.assetCount === 2, JSON.stringify(good.body?.albums?.[0]));
|
|
check('a probe reports download permission', good.body?.canDownload === true, JSON.stringify(good.body?.canDownload));
|
|
check('a probe of a key without asset.download says no', (await user.post('/immich/probe', { url, key: KEY_NODL })).body?.canDownload === false);
|
|
check('a probe stores nothing', (await user.req('/immich/config')).body?.servers?.length === 0);
|
|
|
|
// ---- storing a server ----------------------------------------------------
|
|
const saved = await user.put('/immich/config', { url, key: KEY, name: 'nas' });
|
|
const serverId = saved.body?.server?.id;
|
|
check('a server is saved', saved.status === 200 && typeof serverId === 'string', JSON.stringify(saved.body));
|
|
check('the saved name is kept', saved.body?.server?.name === 'nas', JSON.stringify(saved.body?.server?.name));
|
|
check('the key comes back masked', saved.body?.server?.keyMasked === '••••abcd', JSON.stringify(saved.body?.server?.keyMasked));
|
|
check('the key never comes back in clear', !JSON.stringify(saved.body).includes(KEY), JSON.stringify(saved.body));
|
|
|
|
const config = await user.req('/immich/config');
|
|
check('the saved list suggests no address of its own', config.body?.defaultUrl === undefined, JSON.stringify(config.body?.defaultUrl));
|
|
check('the saved server is listed with its version', config.body?.servers?.[0]?.version === '3.1.0', JSON.stringify(config.body?.servers?.[0]));
|
|
|
|
// A server saved without a name falls back to its host, which is what the tree
|
|
// shows beside the node.
|
|
const unnamed = await user.put('/immich/config', { url, key: KEY });
|
|
check('an unnamed server is named after its host', unnamed.body?.server?.name === '127.0.0.1', JSON.stringify(unnamed.body?.server?.name));
|
|
check('the same address twice is two servers', (await user.req('/immich/config')).body?.servers?.length === 2);
|
|
|
|
// ---- one account cannot touch another's servers --------------------------
|
|
check("another account sees none of it", (await other.req('/immich/config')).body?.servers?.length === 0);
|
|
const stolen = await other.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}`);
|
|
check("another account's server id is unknown", stolen.status === 404, `got ${stolen.status}`);
|
|
const stolenDelete = await other.req(`/immich/config?id=${serverId}`, { method: 'DELETE' });
|
|
check("another account cannot delete it", stolenDelete.status === 404, `got ${stolenDelete.status}`);
|
|
const stolenEdit = await other.put('/immich/albums', { id: serverId, selected: [ALBUM_A] });
|
|
check("another account cannot retick its albums", stolenEdit.status === 404, `got ${stolenEdit.status}`);
|
|
|
|
// ---- ticking albums, and the pasted IDs ---------------------------------
|
|
const ticked = await user.put('/immich/albums', {
|
|
id: serverId,
|
|
selected: [ALBUM_A, `https://immich.example/albums/${ALBUM_B}`, ALBUM_A, 'not-a-uuid', ALBUM_B.toUpperCase()],
|
|
});
|
|
check(
|
|
'pasted ids are cleaned, deduped and capped',
|
|
JSON.stringify(ticked.body?.albums) === JSON.stringify([ALBUM_A, ALBUM_B]),
|
|
JSON.stringify(ticked.body?.albums),
|
|
);
|
|
const empty = await user.put('/immich/albums', { id: serverId, selected: [] });
|
|
check('an empty tick list is kept as "every album"', Array.isArray(empty.body?.albums) && empty.body.albums.length === 0);
|
|
|
|
// ---- the page of photos -------------------------------------------------
|
|
const live = await user.req(`/immich/albums?id=${serverId}`);
|
|
check('the live album list is filtered to the fields the tree needs', live.body?.albums?.[0]?.albumName === 'Nhà', JSON.stringify(live.body?.albums?.[0]));
|
|
check('the live album list keeps the count', live.body?.albums?.[1]?.assetCount === 1, JSON.stringify(live.body?.albums?.[1]));
|
|
|
|
const albumPage = await user.req(`/immich/assets?server=${serverId}&album=${ALBUM_A}&page=1&size=10`);
|
|
check('an album page returns that album only', albumPage.body?.items?.length === 2 && albumPage.body.total === 2, JSON.stringify(albumPage.body));
|
|
check('a row carries what the catalogue needs', albumPage.body?.items?.[0]?.name === 'photo-1.jpg', JSON.stringify(albumPage.body?.items?.[0]));
|
|
check('a short page ends the walk', albumPage.body?.hasMore === false, JSON.stringify(albumPage.body?.hasMore));
|
|
|
|
const allPhotos = await user.req(`/immich/assets?server=${serverId}&album=all&page=1&size=10`);
|
|
check('no album means every photo the key sees', allPhotos.body?.items?.length === 3, JSON.stringify(allPhotos.body?.items?.length));
|
|
const paged = await user.req(`/immich/assets?server=${serverId}&page=2&size=2`);
|
|
check('pages divide the same list', paged.body?.items?.length === 1 && paged.body?.hasMore === false, JSON.stringify(paged.body));
|
|
|
|
const badAlbum = await user.req(`/immich/assets?server=${serverId}&album=nope`);
|
|
check('a malformed album id is refused', badAlbum.status === 400, `got ${badAlbum.status}`);
|
|
|
|
// ---- the pixels ---------------------------------------------------------
|
|
const tile = await user.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}`);
|
|
check('a tile streams through the proxy', tile.status === 200 && tile.body.equals(THUMB), `got ${tile.status}`);
|
|
check('a tile keeps Immich own content type', tile.headers.get('content-type') === 'image/webp', tile.headers.get('content-type') ?? '');
|
|
check('a tile is cacheable for a day', /max-age=86400/.test(tile.headers.get('cache-control') ?? ''), tile.headers.get('cache-control') ?? '');
|
|
check('a tile defaults to the small size', seen.at(-1)?.size === 'thumbnail', JSON.stringify(seen.at(-1)));
|
|
check('a tile travels with the key', seen.at(-1)?.key === KEY, JSON.stringify(seen.at(-1)));
|
|
await user.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}&size=preview`);
|
|
check('the stage size is passed through', seen.at(-1)?.size === 'preview', JSON.stringify(seen.at(-1)));
|
|
await user.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}&size=../../etc`);
|
|
check('an unknown size falls back to the small one', seen.at(-1)?.size === 'thumbnail', JSON.stringify(seen.at(-1)));
|
|
|
|
const badAsset = await user.req(`/immich/thumb?server=${serverId}&id=../secret`);
|
|
check('a malformed asset id never reaches Immich', badAsset.status === 400, `got ${badAsset.status}`);
|
|
const missingAsset = await user.req(`/immich/thumb?server=${serverId}&id=${assetId(9)}`);
|
|
check('a missing asset stays a 404', missingAsset.status === 404, `got ${missingAsset.status}`);
|
|
|
|
const original = await user.req(`/immich/original?server=${serverId}&id=${assetId(1)}`);
|
|
check('an original streams through the proxy', original.status === 200 && original.body.equals(ORIGINAL), `got ${original.status}`);
|
|
check('an original is never cached', original.headers.get('cache-control') === 'no-store', original.headers.get('cache-control') ?? '');
|
|
|
|
// The studio is the only caller of an original, so a key without
|
|
// asset.download has to be a clear refusal rather than a broken download.
|
|
const noDownload = await user.put('/immich/config', { url, key: KEY_NODL, name: 'reader' });
|
|
const readerId = noDownload.body?.server?.id;
|
|
check('a key without download is still accepted', noDownload.status === 200 && noDownload.body?.server?.canDownload === false, JSON.stringify(noDownload.body));
|
|
const refused = await user.req(`/immich/original?server=${readerId}&id=${assetId(1)}`);
|
|
check('its original is refused with the reason', refused.status === 502 && refused.body?.error === 'auth', JSON.stringify(refused.body));
|
|
|
|
// ---- a share link is one album ------------------------------------------
|
|
const shareProbe = await user.post('/immich/probe', { url, key: SHARE_KEY, type: 'share' });
|
|
check('a share key probes into its own single album', shareProbe.body?.albums?.length === 1, JSON.stringify(shareProbe.body?.albums));
|
|
check('the shared album is named from Immich', shareProbe.body?.albums?.[0]?.albumName === 'Album chia sẻ', JSON.stringify(shareProbe.body?.albums?.[0]));
|
|
const shareSaved = await user.put('/immich/config', { url, key: SHARE_KEY, type: 'share', name: 'Chia sẻ' });
|
|
const shareId = shareSaved.body?.server?.id;
|
|
check('a share server is stored', shareSaved.status === 200 && typeof shareId === 'string', JSON.stringify(shareSaved.body));
|
|
const shareAssets = await user.req(`/immich/assets?server=${shareId}&page=1&size=10`);
|
|
check('a share key lists its album whole', shareAssets.body?.items?.length === 2, JSON.stringify(shareAssets.body));
|
|
const shareTile = await user.req(`/immich/thumb?server=${shareId}&id=${assetId(5)}`);
|
|
check('a share tile streams too', shareTile.status === 200 && shareTile.body.equals(THUMB), `got ${shareTile.status}`);
|
|
check('a share request travels with ?key=', seen.at(-1)?.shareKey === SHARE_KEY, JSON.stringify(seen.at(-1)));
|
|
check('a share key is never sent as an x-api-key header', seen.at(-1)?.apiKey === null, JSON.stringify(seen.at(-1)));
|
|
|
|
// ---- forgetting a server -------------------------------------------------
|
|
const forgotten = await user.req(`/immich/config?id=${shareId}`, { method: 'DELETE' });
|
|
check('a server can be forgotten', forgotten.status === 200 && forgotten.body?.servers?.length === 3, JSON.stringify(forgotten.body?.servers?.length));
|
|
check('its id stops working', (await user.req(`/immich/thumb?server=${shareId}&id=${assetId(5)}`)).status === 404);
|
|
const unknownDelete = await user.req('/immich/config?id=deadbeef', { method: 'DELETE' });
|
|
check('forgetting an unknown server is a 404', unknownDelete.status === 404, `got ${unknownDelete.status}`);
|
|
|
|
// ---- no key ever reaches the browser ------------------------------------
|
|
const everything = JSON.stringify([
|
|
config.body,
|
|
saved.body,
|
|
(await user.req('/immich/config')).body,
|
|
live.body,
|
|
albumPage.body,
|
|
]);
|
|
check('no route echoes a key', !everything.includes(KEY) && !everything.includes(SHARE_KEY));
|
|
check('the server log holds no key either', !serverLog.includes(KEY) && !serverLog.includes(SHARE_KEY));
|
|
} catch (err) {
|
|
fail++;
|
|
console.log(`FAIL harness :: ${err && err.stack ? err.stack : err}`);
|
|
console.log(serverLog.slice(-2000));
|
|
} finally {
|
|
server.kill('SIGTERM');
|
|
fake.server.close();
|
|
rmSync(DATA_DIR, { recursive: true, force: true });
|
|
}
|
|
|
|
console.log(`\n${pass} passed, ${fail} failed`);
|
|
process.exit(fail === 0 ? 0 : 1);
|