Files
TREK/server/tests/integration/memories-synology.test.ts
jubnl 5cc81ae4b0 refactor(server): replace node-fetch with native fetch + undici, fix photo integrations
Replace node-fetch v2 with Node 22's built-in fetch API across the entire server.
Add undici as an explicit dependency to provide the dispatcher API needed for
DNS pinning (SSRF rebinding prevention) in ssrfGuard.ts. All seven service files
that used a plain `import fetch from 'node-fetch'` are updated to use the global.
The ssrfGuard safeFetch/createPinnedAgent is rewritten as createPinnedDispatcher
using an undici Agent, with correct handling of the `all: true` lookup callback
required by Node 18+. The collabService dynamic require() and notifications agent
option are updated to use the dispatcher pattern. Test mocks are migrated from
vi.mock('node-fetch') to vi.stubGlobal('fetch'), and streaming test fixtures are
updated to use Web ReadableStream instead of Node Readable.

Fix several bugs in the Synology and Immich photo integrations:
- pipeAsset: guard against setting headers after stream has already started
- _getSynologySession: clear stale SID and re-login when decrypt_api_key returns null
  instead of propagating success(null) downstream
- _requestSynologyApi: return retrySession error (not stale session) on retry failure;
  also retry on error codes 106 (timeout) and 107 (duplicate login), not only 119
- searchSynologyPhotos: fix incorrect total field type (Synology list_item returns no
  total); hasMore correctly uses allItems.length === limit
- _splitPackedSynologyId: validate cache_key format before use; callers return 400
- getImmichCredentials / _getSynologyCredentials: treat null from decrypt_api_key as
  a missing-credentials condition rather than casting null to string
- Synology size param: enforce allowlist ['sm', 'm', 'xl'] per API documentation
2026-04-05 21:12:51 +02:00

546 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Synology Photos integration tests (SYNO-001 SYNO-040).
* Covers settings, connection test, search, albums, asset streaming, and access control.
*
* safeFetch is mocked to return fake Synology API JSON responses based on the `api`
* query/body parameter. The Synology service uses POST form-body requests so the mock
* inspects URLSearchParams to dispatch the right fake response.
*
* No real HTTP calls are made.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
// ── SSRF guard mock — routes all Synology API calls to fake responses ─────────
vi.mock('../../src/utils/ssrfGuard', async () => {
const actual = await vi.importActual<typeof import('../../src/utils/ssrfGuard')>('../../src/utils/ssrfGuard');
function makeFakeSynologyFetch(url: string, init?: any) {
const u = String(url);
// Determine which API was called from the URL query param (e.g. ?api=SYNO.API.Auth)
// or from the body for POST requests.
let apiName = '';
try {
apiName = new URL(u).searchParams.get('api') || '';
} catch {}
if (!apiName && init?.body) {
const body = init.body instanceof URLSearchParams
? init.body
: new URLSearchParams(String(init.body));
apiName = body.get('api') || '';
}
// Auth login — used by settings save, status, test-connection
if (apiName === 'SYNO.API.Auth') {
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: () => Promise.resolve({ success: true, data: { sid: 'fake-session-id-abc' } }),
body: null,
});
}
// Album list
if (apiName === 'SYNO.Foto.Browse.Album') {
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: () => Promise.resolve({
success: true,
data: {
list: [
{ id: 1, name: 'Summer Trip', item_count: 15 },
{ id: 2, name: 'Winter Holiday', item_count: 8 },
],
},
}),
body: null,
});
}
// Search photos
if (apiName === 'SYNO.Foto.Search.Search') {
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: () => Promise.resolve({
success: true,
data: {
list: [
{
id: 101,
filename: 'photo1.jpg',
filesize: 1024000,
time: 1717228800, // 2024-06-01 in Unix timestamp
additional: {
thumbnail: { cache_key: '101_cachekey' },
address: { city: 'Tokyo', country: 'Japan', state: 'Tokyo' },
exif: { camera: 'Sony A7IV', focal_length: '50', aperture: '1.8', exposure_time: '1/250', iso: 400 },
gps: { latitude: 35.6762, longitude: 139.6503 },
resolution: { width: 6000, height: 4000 },
orientation: 1,
description: 'Tokyo street',
},
},
],
total: 1,
},
}),
body: null,
});
}
// Browse items (for album sync or asset info)
if (apiName === 'SYNO.Foto.Browse.Item') {
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: () => Promise.resolve({
success: true,
data: {
list: [
{
id: 101,
filename: 'photo1.jpg',
filesize: 1024000,
time: 1717228800,
additional: {
thumbnail: { cache_key: '101_cachekey' },
address: { city: 'Tokyo', country: 'Japan', state: 'Tokyo' },
exif: { camera: 'Sony A7IV' },
gps: { latitude: 35.6762, longitude: 139.6503 },
resolution: { width: 6000, height: 4000 },
orientation: 1,
description: null,
},
},
],
},
}),
body: null,
});
}
// Thumbnail stream
if (apiName === 'SYNO.Foto.Thumbnail') {
const imageBytes = Buffer.from('fake-synology-thumbnail');
return Promise.resolve({
ok: true, status: 200,
headers: { get: (h: string) => h === 'content-type' ? 'image/jpeg' : null },
body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }),
});
}
// Original download
if (apiName === 'SYNO.Foto.Download') {
const imageBytes = Buffer.from('fake-synology-original');
return Promise.resolve({
ok: true, status: 200,
headers: { get: (h: string) => h === 'content-type' ? 'image/jpeg' : null },
body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }),
});
}
return Promise.reject(new Error(`Unexpected safeFetch call to Synology: ${u}, api=${apiName}`));
}
return {
...actual,
checkSsrf: vi.fn().mockImplementation(async (rawUrl: string) => {
try {
const url = new URL(rawUrl);
const h = url.hostname;
if (h === '127.0.0.1' || h === '::1' || h === 'localhost') {
return { allowed: false, isPrivate: true, error: 'Loopback not allowed' };
}
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h)) {
return { allowed: false, isPrivate: true, error: 'Private IP not allowed' };
}
return { allowed: true, isPrivate: false, resolvedIp: '93.184.216.34' };
} catch {
return { allowed: false, isPrivate: false, error: 'Invalid URL' };
}
}),
safeFetch: vi.fn().mockImplementation(makeFakeSynologyFetch),
};
});
import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser, createTrip, addTripMember, addTripPhoto, setSynologyCredentials } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { safeFetch } from '../../src/utils/ssrfGuard';
const app: Application = createApp();
const SYNO = '/api/integrations/memories/synologyphotos';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
});
afterAll(() => testDb.close());
// ── Settings ──────────────────────────────────────────────────────────────────
describe('Synology settings', () => {
it('SYNO-001 — GET /settings when not configured returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get(`${SYNO}/settings`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
});
it('SYNO-002 — PUT /settings saves credentials and returns success', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put(`${SYNO}/settings`)
.set('Cookie', authCookie(user.id))
.send({
synology_url: 'https://synology.example.com',
synology_username: 'admin',
synology_password: 'secure-password',
});
expect(res.status).toBe(200);
const row = testDb.prepare('SELECT synology_url, synology_username FROM users WHERE id = ?').get(user.id) as any;
expect(row.synology_url).toBe('https://synology.example.com');
expect(row.synology_username).toBe('admin');
});
it('SYNO-003 — PUT /settings with SSRF-blocked URL returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put(`${SYNO}/settings`)
.set('Cookie', authCookie(user.id))
.send({
synology_url: 'http://192.168.1.100',
synology_username: 'admin',
synology_password: 'pass',
});
expect(res.status).toBe(400);
});
it('SYNO-004 — PUT /settings without URL returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put(`${SYNO}/settings`)
.set('Cookie', authCookie(user.id))
.send({ synology_username: 'admin', synology_password: 'pass' }); // no url
expect(res.status).toBe(400);
});
});
// ── Connection ────────────────────────────────────────────────────────────────
describe('Synology connection', () => {
it('SYNO-010 — GET /status when not configured returns { connected: false }', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get(`${SYNO}/status`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.connected).toBe(false);
});
it('SYNO-011 — GET /status when configured returns { connected: true }', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
const res = await request(app)
.get(`${SYNO}/status`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.connected).toBe(true);
});
it('SYNO-012 — POST /test with valid credentials returns { connected: true }', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post(`${SYNO}/test`)
.set('Cookie', authCookie(user.id))
.send({
synology_url: 'https://synology.example.com',
synology_username: 'admin',
synology_password: 'secure-password',
});
expect(res.status).toBe(200);
expect(res.body.connected).toBe(true);
});
it('SYNO-013 — POST /test with missing fields returns error', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post(`${SYNO}/test`)
.set('Cookie', authCookie(user.id))
.send({ synology_url: 'https://synology.example.com' }); // missing username+password
expect(res.status).toBe(200);
expect(res.body.connected).toBe(false);
expect(res.body.error).toBeDefined();
});
});
// ── Search & Albums ───────────────────────────────────────────────────────────
describe('Synology search and albums', () => {
it('SYNO-020 — POST /search returns mapped assets', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
const res = await request(app)
.post(`${SYNO}/search`)
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(200);
expect(Array.isArray(res.body.assets)).toBe(true);
expect(res.body.assets[0]).toMatchObject({ city: 'Tokyo', country: 'Japan' });
});
it('SYNO-021 — POST /search when upstream throws propagates 500', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
// Auth call succeeds, search call throws a network error
vi.mocked(safeFetch)
.mockResolvedValueOnce({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
body: null,
} as any)
.mockRejectedValueOnce(new Error('Synology unreachable'));
const res = await request(app)
.post(`${SYNO}/search`)
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(500);
expect(res.body.error).toBeDefined();
});
it('SYNO-022 — GET /albums returns album list', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
const res = await request(app)
.get(`${SYNO}/albums`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.albums)).toBe(true);
expect(res.body.albums).toHaveLength(2);
expect(res.body.albums[0]).toMatchObject({ albumName: 'Summer Trip', assetCount: 15 });
});
});
// ── Asset access ──────────────────────────────────────────────────────────────
describe('Synology asset access', () => {
it('SYNO-030 — GET /assets/info returns metadata for own photo', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
addTripPhoto(testDb, trip.id, user.id, '101_cachekey', 'synologyphotos', { shared: false });
const res = await request(app)
.get(`${SYNO}/assets/${trip.id}/101_cachekey/${user.id}/info`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ city: 'Tokyo', country: 'Japan' });
});
it('SYNO-031 — GET /assets/info by non-owner of unshared photo returns 403', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
addTripPhoto(testDb, trip.id, owner.id, '101_cachekey', 'synologyphotos', { shared: false });
const res = await request(app)
.get(`${SYNO}/assets/${trip.id}/101_cachekey/${owner.id}/info`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(403);
});
it('SYNO-032 — GET /assets/thumbnail streams image data for own photo', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
addTripPhoto(testDb, trip.id, user.id, '101_cachekey', 'synologyphotos', { shared: false });
const res = await request(app)
.get(`${SYNO}/assets/${trip.id}/101_cachekey/${user.id}/thumbnail`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('image/jpeg');
});
it('SYNO-033 — GET /assets/original streams image data for shared photo', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
setSynologyCredentials(testDb, owner.id, 'https://synology.example.com', 'admin', 'pass');
addTripPhoto(testDb, trip.id, owner.id, '101_cachekey', 'synologyphotos', { shared: true });
const res = await request(app)
.get(`${SYNO}/assets/${trip.id}/101_cachekey/${owner.id}/original`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('image/jpeg');
});
it('SYNO-034 — GET /assets with invalid kind returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripPhoto(testDb, trip.id, user.id, '101_cachekey', 'synologyphotos', { shared: false });
const res = await request(app)
.get(`${SYNO}/assets/${trip.id}/101_cachekey/${user.id}/badkind`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
});
it('SYNO-035 — GET /assets/info where trip does not exist returns 403', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
// Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily)
testDb.exec('PRAGMA foreign_keys = OFF');
testDb.prepare(
'INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'
).run(9999, owner.id, '101_cachekey', 'synologyphotos', 1);
testDb.exec('PRAGMA foreign_keys = ON');
const res = await request(app)
.get(`${SYNO}/assets/9999/101_cachekey/${owner.id}/info`)
.set('Cookie', authCookie(member.id));
// canAccessUserPhoto: shared photo found, but canAccessTrip(9999) → null → false → 403
expect(res.status).toBe(403);
});
it('SYNO-036 — GET /assets/info when upstream throws propagates 500', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
addTripPhoto(testDb, trip.id, user.id, '101_cachekey', 'synologyphotos', { shared: false });
// Auth call succeeds, Browse.Item call throws a network error
vi.mocked(safeFetch)
.mockResolvedValueOnce({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
body: null,
} as any)
.mockRejectedValueOnce(new Error('network failure'));
const res = await request(app)
.get(`${SYNO}/assets/${trip.id}/101_cachekey/${user.id}/info`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(500);
expect(res.body.error).toBeDefined();
});
});
// ── Auth checks ───────────────────────────────────────────────────────────────
describe('Synology auth checks', () => {
it('SYNO-040 — GET /settings without auth returns 401', async () => {
expect((await request(app).get(`${SYNO}/settings`)).status).toBe(401);
});
it('SYNO-040 — PUT /settings without auth returns 401', async () => {
expect((await request(app).put(`${SYNO}/settings`)).status).toBe(401);
});
it('SYNO-040 — GET /status without auth returns 401', async () => {
expect((await request(app).get(`${SYNO}/status`)).status).toBe(401);
});
it('SYNO-040 — POST /test without auth returns 401', async () => {
expect((await request(app).post(`${SYNO}/test`)).status).toBe(401);
});
it('SYNO-040 — GET /albums without auth returns 401', async () => {
expect((await request(app).get(`${SYNO}/albums`)).status).toBe(401);
});
it('SYNO-040 — POST /search without auth returns 401', async () => {
expect((await request(app).post(`${SYNO}/search`)).status).toBe(401);
});
it('SYNO-040 — GET /assets/info without auth returns 401', async () => {
expect((await request(app).get(`${SYNO}/assets/1/photo-x/1/info`)).status).toBe(401);
});
it('SYNO-040 — GET /assets/thumbnail without auth returns 401', async () => {
expect((await request(app).get(`${SYNO}/assets/1/photo-x/1/thumbnail`)).status).toBe(401);
});
});