Files
TREK/server/tests/integration/memories-synology.test.ts
T
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

1477 lines
58 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 { buildApp } from '../../src/bootstrap';
import { db as testDb } from '../../src/db/database';
import { PhotoProviders } from '../../src/db/entities/PhotoProviders.entity';
import { TrekPhotos } from '../../src/db/entities/TrekPhotos.entity';
import { TripAlbumLinks } from '../../src/db/entities/TripAlbumLinks.entity';
import { TripPhotos } from '../../src/db/entities/TripPhotos.entity';
import { Users } from '../../src/db/entities/Users.entity';
import { encrypt_api_key } from '../../src/nest/common/crypto/apiKeyCrypto';
import { decrypt_api_key } from '../../src/nest/common/crypto/apiKeyCrypto';
// ── Passphrase persistence fixes ─────────────────────────────────────────────
import { withRequestContext } from '../../src/nest/database/request-context';
import { TrekPhotoRegistrationService } from '../../src/nest/photos/trek-photo-registration.service';
import { safeFetch } from '../../src/utils/ssrfGuard';
import { authCookie } from '../helpers/auth';
import { createUser, createTrip, addTripMember, addTripPhoto, setSynologyCredentials } from '../helpers/factories';
// ── Album sync ────────────────────────────────────────────────────────────────
import { addAlbumLink } from '../helpers/factories';
import type { FactoryOrm } from '../helpers/factories/context';
import {
createRow,
deleteRows,
findRow,
findRows,
insertRow,
insertRowIgnoringConflict,
updateRows,
} from '../helpers/factories/rows';
import { readUser } from '../helpers/factories/users';
import { resetTestDb, resetRateLimits, setAddonEnabled } from '../helpers/test-db';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import type { Application } from 'express';
import request from 'supertest';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
// ── 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 = '';
let params = new URLSearchParams();
try {
params = new URL(u).searchParams;
apiName = params.get('api') || '';
} catch {}
if (!apiName && init?.body) {
params = init.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init.body));
apiName = params.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') {
if (!['sm', 'm', 'xl', 'preview'].includes(params.get('size') || ''))
return Promise.reject(new Error(`Unexpected thumbnail size: ${params.get('size')}`));
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();
},
}),
});
}
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),
__fakeSynologyFetch: makeFakeSynologyFetch,
};
});
let nestApp: INestApplication;
let app: Application;
let orm: FactoryOrm;
const SYNO = '/api/integrations/memories/synologyphotos';
/** The user's photo rows on the trip, each with the provider and passphrase of the photo it points at. */
async function tripPhotoRows(tripId: number, userId: number) {
const rows = [];
for (const tp of await findRows(orm, TripPhotos, { trip: tripId, user: userId })) {
const tkp = await findRow(orm, TrekPhotos, { id: tp.photo_id });
if (tkp) rows.push({ ...tp, provider: tkp.provider, passphrase: tkp.passphrase });
}
return rows;
}
beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
orm = nestApp.get(MikroORM);
});
beforeEach(async () => {
resetTestDb(testDb);
await resetRateLimits(nestApp);
});
afterAll(async () => {
await nestApp.close();
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 = await readUser(orm, user.id);
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 });
});
});
// ── Album listing — multi-source merge ───────────────────────────────────────
describe('Synology listSynologyAlbums multi-source merge', () => {
// Capture and restore the default safeFetch implementation around each test
// in this block so the persistent mockImplementation we set doesn't leak.
let _savedImpl: ((...args: any[]) => any) | undefined;
beforeEach(() => {
_savedImpl = vi.mocked(safeFetch).getMockImplementation();
});
afterEach(() => {
if (_savedImpl) vi.mocked(safeFetch).mockImplementation(_savedImpl);
});
it('SYNO-027 — personal-only: shared and shared-with-me return failure → merged result contains personal albums, no error', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
// Always read both URL params and body params; body takes precedence for request-specific fields.
const urlParams = (() => {
try {
return new URL(String(_url)).searchParams;
} catch {
return new URLSearchParams();
}
})();
const bodyParams: URLSearchParams =
init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
const api = urlParams.get('api') || bodyParams.get('api') || '';
const category = bodyParams.get('category') || urlParams.get('category');
if (api === 'SYNO.API.Auth') {
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'sid-027' } }),
body: null,
} as any);
}
if (api === 'SYNO.Foto.Browse.Album') {
if (!category) {
// personal albums
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { list: [{ id: 1, name: 'Personal Album', item_count: 5 }] } }),
body: null,
} as any);
}
// shared category → failure
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: false, error: { code: 400 } }),
body: null,
} as any);
}
if (api === 'SYNO.Foto.Sharing.Misc') {
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: false, error: { code: 400 } }),
body: null,
} as any);
}
return Promise.reject(new Error(`Unexpected API: ${api}`));
});
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(1);
expect(res.body.albums[0]).toMatchObject({ albumName: 'Personal Album', assetCount: 5 });
});
it('SYNO-028 — full merge: personal + shared (with passphrase) + shared-with-me (with sharing_info.passphrase) → 4 albums with correct passphrases', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
const urlParams = (() => {
try {
return new URL(String(_url)).searchParams;
} catch {
return new URLSearchParams();
}
})();
const bodyParams: URLSearchParams =
init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
const api = urlParams.get('api') || bodyParams.get('api') || '';
const category = bodyParams.get('category') || urlParams.get('category');
if (api === 'SYNO.API.Auth') {
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'sid-028' } }),
body: null,
} as any);
}
if (api === 'SYNO.Foto.Browse.Album') {
if (!category) {
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({
success: true,
data: {
list: [
{ id: 10, name: 'Alpha Album', item_count: 3 },
{ id: 11, name: 'Beta Album', item_count: 7 },
],
},
}),
body: null,
} as any);
}
// shared category — one album with passphrase
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({
success: true,
data: { list: [{ id: 20, name: 'Shared Out', item_count: 2, passphrase: 'pp-abc' }] },
}),
body: null,
} as any);
}
if (api === 'SYNO.Foto.Sharing.Misc') {
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({
success: true,
data: { list: [{ id: 30, name: 'Shared With Me', item_count: 4, sharing_info: { passphrase: 'pp-xyz' } }] },
}),
body: null,
} as any);
}
return Promise.reject(new Error(`Unexpected API: ${api}`));
});
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(4);
const byName = (name: string) => res.body.albums.find((a: any) => a.albumName === name);
expect(byName('Alpha Album')).toMatchObject({ id: '10', assetCount: 3 });
expect(byName('Beta Album')).toMatchObject({ id: '11', assetCount: 7 });
expect(byName('Shared Out')).toMatchObject({ id: '20', passphrase: 'pp-abc' });
expect(byName('Shared With Me')).toMatchObject({ id: '30', passphrase: 'pp-xyz' });
// personal albums carry no passphrase
expect(byName('Alpha Album').passphrase).toBeUndefined();
});
it('SYNO-029 — dedup: same album id=99 in personal and shared-with-me → last-write-wins gives passphrase from shared-with-me', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
const urlParams = (() => {
try {
return new URL(String(_url)).searchParams;
} catch {
return new URLSearchParams();
}
})();
const bodyParams: URLSearchParams =
init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
const api = urlParams.get('api') || bodyParams.get('api') || '';
const category = bodyParams.get('category') || urlParams.get('category');
if (api === 'SYNO.API.Auth') {
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'sid-029' } }),
body: null,
} as any);
}
if (api === 'SYNO.Foto.Browse.Album') {
if (!category) {
// personal: album id=99 without passphrase
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Dup Album', item_count: 10 }] } }),
body: null,
} as any);
}
// shared: no entries
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { list: [] } }),
body: null,
} as any);
}
if (api === 'SYNO.Foto.Sharing.Misc') {
// shared-with-me: same album id=99 with passphrase
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({
success: true,
data: { list: [{ id: 99, name: 'Dup Album', item_count: 10, passphrase: 'pp-dup' }] },
}),
body: null,
} as any);
}
return Promise.reject(new Error(`Unexpected API: ${api}`));
});
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);
// Deduplicated to a single album
expect(res.body.albums).toHaveLength(1);
expect(res.body.albums[0]).toMatchObject({ id: '99', albumName: 'Dup Album' });
// shared-with-me wins (last write) → passphrase present
expect(res.body.albums[0].passphrase).toBe('pp-dup');
});
});
// ── 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-032b — GET /api/photos/:id/thumbnail uses an allowed Synology thumbnail size', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
const trekPhotoId = await insertRow(orm, TrekPhotos, {
provider: 'synologyphotos',
asset_id: '101_cachekey',
owner: user.id,
});
vi.mocked(safeFetch).mockClear();
const res = await request(app).get(`/api/photos/${trekPhotoId}/thumbnail`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
});
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');
const known = await findRow(orm, TrekPhotos, {
provider: 'synologyphotos',
asset_id: '101_cachekey',
owner: owner.id,
});
const tkpSyno35 =
known?.id ??
(await insertRow(orm, TrekPhotos, { provider: 'synologyphotos', asset_id: '101_cachekey', owner: owner.id }));
await insertRow(orm, TripPhotos, { trip: 9999, user: owner.id, photo: tkpSyno35, shared: 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);
});
});
describe('Synology syncSynologyAlbumLink', () => {
it('SYNO-050 — POST sync happy path: trip owner with album link saves photos to DB', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
// The migration inserts synologyphotos with enabled=0; ensure it is enabled for this test.
// A provider only counts as enabled under an enabled journey addon (also seeded off).
await updateRows(orm, PhotoProviders, { id: 'synologyphotos' }, { enabled: 1 });
setAddonEnabled(testDb, 'journey', true);
// album_id must be a numeric string so getAlbumIdFromLink returns it and
// syncSynologyAlbumLink passes Number(album_id) to the API.
const link = addAlbumLink(testDb, trip.id, user.id, 'synologyphotos', '1', 'Summer Trip');
const res = await request(app)
.post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(typeof res.body.added).toBe('number');
expect(typeof res.body.total).toBe('number');
// Verify photos were inserted into the DB
const photos = await tripPhotoRows(trip.id, user.id);
expect(photos.length).toBeGreaterThan(0);
expect(photos[0].provider).toBe('synologyphotos');
});
it('SYNO-051 — POST sync when user is not a trip member returns 404', async () => {
const { user: owner } = createUser(testDb);
const { user: outsider } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
setSynologyCredentials(testDb, owner.id, 'https://synology.example.com', 'admin', 'pass');
const link = addAlbumLink(testDb, trip.id, owner.id, 'synologyphotos', '1', 'Summer Trip');
const res = await request(app)
.post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(outsider.id));
expect(res.status).toBe(404);
});
it('SYNO-052 — POST sync when Synology is not configured returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// No credentials — album link still exists for the user
const link = addAlbumLink(testDb, trip.id, user.id, 'synologyphotos', '1', 'Summer Trip');
const res = await request(app)
.post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('SYNO-053 — POST sync without auth returns 401', async () => {
expect((await request(app).post(`${SYNO}/trips/1/album-links/1/sync`)).status).toBe(401);
});
it('SYNO-054 — POST sync with passphrase link: uses passphrase in item-list call and persists encrypted passphrase on trek_photos', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
await updateRows(orm, PhotoProviders, { id: 'synologyphotos' }, { enabled: 1 });
setAddonEnabled(testDb, 'journey', true);
// Insert a link with an encrypted passphrase directly into the DB.
const rawPassphrase = 'syno-share-pass-abc';
const link = await createRow(orm, TripAlbumLinks, {
trip: trip.id,
user: user.id,
provider: 'synologyphotos',
album_id: '99',
album_name: 'Shared Album',
passphrase: encrypt_api_key(rawPassphrase),
});
// Override safeFetch so browse-item only succeeds when called with the passphrase param.
vi.mocked(safeFetch).mockImplementation(async (url: any, init?: any) => {
const bodyParams =
init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
const apiName = bodyParams.get('api') || (new URL(String(url)).searchParams.get('api') ?? '');
if (apiName === 'SYNO.API.Auth') {
return {
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'fake-sid-054' } }),
body: null,
} as any;
}
if (apiName === 'SYNO.Foto.Browse.Item') {
// Only respond successfully when the passphrase param is present.
if (bodyParams.get('passphrase') !== rawPassphrase) {
return {
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: false, error: { code: 105 } }),
body: null,
} as any;
}
return {
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({
success: true,
data: {
list: [
{
id: 201,
filename: 'shared.jpg',
filesize: 512000,
time: 1717228800,
additional: { thumbnail: { cache_key: '201_sharedkey' } },
},
],
},
}),
body: null,
} as any;
}
return Promise.reject(new Error(`SYNO-054: unexpected safeFetch call: api=${apiName}`));
});
const res = await request(app)
.post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.added).toBeGreaterThan(0);
// The trek_photos row for the synced photo must have a non-null passphrase.
const [photo] = await tripPhotoRows(trip.id, user.id);
expect(photo).toBeDefined();
expect(photo!.passphrase).not.toBeNull();
});
});
// ── Session retry logic ───────────────────────────────────────────────────────
describe('Synology session retry on error codes 106/107/119', () => {
it('SYNO-060 — request retries with fresh session when API returns error code 119', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
// Clear previous call history so the count only reflects this test's calls
vi.mocked(safeFetch).mockClear();
// Call sequence:
// 1. Auth login (fresh session — no cached SID) → success with sid
// 2. SYNO.Foto.Browse.Album call → returns { success: false, error: { code: 119 } }
// 3. Auth login again (retry session after clearing SID) → success with new sid
// 4. SYNO.Foto.Browse.Album retry call → success
vi.mocked(safeFetch)
.mockResolvedValueOnce({
// call 1: initial login
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'first-sid' } }),
body: null,
} as any)
.mockResolvedValueOnce({
// call 2: album list → session expired (119)
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: false, error: { code: 119 } }),
body: null,
} as any)
.mockResolvedValueOnce({
// call 3: retry login after clearing SID
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'second-sid' } }),
body: null,
} as any)
.mockResolvedValueOnce({
// call 4: retry album list → success
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({
success: true,
data: {
list: [{ id: 99, name: 'Retry Album', item_count: 5 }],
},
}),
body: null,
} as any);
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[0]).toMatchObject({ albumName: 'Retry Album' });
// Five safeFetch calls: login, failed album list (119), re-login, successful album list retry,
// plus one additional call for the shared or shared-with-me source (handled by default mock)
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(5);
});
it('SYNO-061 — request retries with fresh session when API returns error code 106', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
vi.mocked(safeFetch).mockClear();
vi.mocked(safeFetch)
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'sid-one' } }),
body: null,
} as any)
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: false, error: { code: 106 } }),
body: null,
} as any)
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'sid-two' } }),
body: null,
} as any)
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({
success: true,
data: { list: [{ id: 3, name: 'Timeout Album', item_count: 2 }] },
}),
body: null,
} as any);
const res = await request(app).get(`${SYNO}/albums`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.albums[0]).toMatchObject({ albumName: 'Timeout Album' });
// Five safeFetch calls: login, failed album list (106), re-login, successful album list retry,
// plus one additional call for the shared or shared-with-me source (handled by default mock)
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(5);
});
});
// ── Date range search ─────────────────────────────────────────────────────────
describe('Synology searchSynologyPhotos date range', () => {
it('SYNO-070 — POST /search with from/to passes start_time and end_time to Synology API', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
// Capture the body sent on the search call (second safeFetch call after auth)
let capturedBody: URLSearchParams | null = null;
vi.mocked(safeFetch)
.mockResolvedValueOnce({
// login
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
body: null,
} as any)
.mockImplementationOnce((_url: string, init?: any) => {
capturedBody =
init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({
success: true,
data: {
list: [
{
id: 201,
filename: 'dated.jpg',
filesize: 512000,
time: 1717228800,
additional: {
thumbnail: { cache_key: '201_abc' },
address: { city: 'Kyoto', country: 'Japan', state: 'Kyoto' },
exif: {},
gps: { latitude: 35.0116, longitude: 135.7681 },
resolution: { width: 4000, height: 3000 },
orientation: 1,
description: null,
},
},
],
},
}),
body: null,
} as any);
});
const res = await request(app)
.post(`${SYNO}/search`)
.set('Cookie', authCookie(user.id))
.send({ from: '2024-06-01', to: '2024-06-30' });
expect(res.status).toBe(200);
expect(Array.isArray(res.body.assets)).toBe(true);
expect(res.body.assets[0]).toMatchObject({ lat: 35.0116, lng: 135.7681 });
// Verify date parameters were forwarded in the Synology API request body
expect(capturedBody).not.toBeNull();
const startTime = capturedBody!.get('start_time');
const endTime = capturedBody!.get('end_time');
expect(startTime).toBeDefined();
expect(Number(startTime)).toBeGreaterThan(0);
expect(endTime).toBeDefined();
expect(Number(endTime)).toBeGreaterThan(Number(startTime));
const additional = JSON.parse(capturedBody!.get('additional') || '[]') as string[];
expect(additional).toContain('gps');
});
it('SYNO-071 — POST /search without date range omits start_time and end_time', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
let capturedBody: URLSearchParams | null = null;
vi.mocked(safeFetch)
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
body: null,
} as any)
.mockImplementationOnce((_url: string, init?: any) => {
capturedBody =
init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { list: [] } }),
body: null,
} as any);
});
const res = await request(app).post(`${SYNO}/search`).set('Cookie', authCookie(user.id)).send({});
expect(res.status).toBe(200);
expect(capturedBody).not.toBeNull();
expect(capturedBody!.get('start_time')).toBeNull();
expect(capturedBody!.get('end_time')).toBeNull();
});
});
// ── Search pagination ─────────────────────────────────────────────────────────
describe('Synology search pagination', () => {
it('SYNO-025 — POST /search with { page: 2, size: 50 } sends offset=50 and limit=50 to Synology API', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
let capturedBody: URLSearchParams | null = null;
vi.mocked(safeFetch)
.mockResolvedValueOnce({
// login
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
body: null,
} as any)
.mockImplementationOnce((_url: string, init?: any) => {
capturedBody =
init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { list: [] } }),
body: null,
} as any);
});
const res = await request(app)
.post(`${SYNO}/search`)
.set('Cookie', authCookie(user.id))
.send({ page: 2, size: 50 });
expect(res.status).toBe(200);
expect(capturedBody).not.toBeNull();
// With the fix: limit=50 is resolved first, then offset = (2-1)*50 = 50
expect(capturedBody!.get('offset')).toBe('50');
expect(capturedBody!.get('limit')).toBe('50');
});
it('SYNO-026 — POST /search with { page: 3, size: 25 } sends offset=50 and limit=25 to Synology API', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
let capturedBody: URLSearchParams | null = null;
vi.mocked(safeFetch)
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
body: null,
} as any)
.mockImplementationOnce((_url: string, init?: any) => {
capturedBody =
init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { list: [] } }),
body: null,
} as any);
});
const res = await request(app)
.post(`${SYNO}/search`)
.set('Cookie', authCookie(user.id))
.send({ page: 3, size: 25 });
expect(res.status).toBe(200);
expect(capturedBody).not.toBeNull();
// page 3 → page index = 2 (after subtracting 1), offset = 2 * 25 = 50
expect(capturedBody!.get('offset')).toBe('50');
expect(capturedBody!.get('limit')).toBe('25');
});
});
// ── SSRF catch branch in _fetchSynologyJson ────────────────────────────────────
describe('Synology SSRF blocked error handling', () => {
it('SYNO-080 — safeFetch throwing SsrfBlockedError for private IP URL returns connected: false', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'http://192.168.1.200', 'admin', 'pass');
const { SsrfBlockedError: SsrfErr } = await import('../../src/utils/ssrfGuard');
// Make safeFetch throw SsrfBlockedError — simulating the SSRF guard blocking the private IP.
// _fetchSynologyJson catches SsrfBlockedError and returns fail(message, 400).
// getSynologyStatus receives the failure from _getSynologySession and returns { connected: false }.
vi.mocked(safeFetch).mockRejectedValueOnce(new SsrfErr('Private IP not allowed'));
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-081 — safeFetch throwing SsrfBlockedError during one album source is swallowed; other sources still return albums', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
const { SsrfBlockedError: SsrfErr } = await import('../../src/utils/ssrfGuard');
const emptyAlbumResponse = {
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({
success: true,
data: { list: [{ id: 99, name: 'Shared Album', item_count: 2, passphrase: 'pp-test' }] },
}),
body: null,
} as any;
// Auth succeeds, personal album source throws SSRF, shared + shared-with-me succeed.
// listSynologyAlbums uses Promise.allSettled so the SSRF failure is logged and skipped.
vi.mocked(safeFetch)
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'sid-x' } }),
body: null,
} as any)
.mockRejectedValueOnce(new SsrfErr('Private IP detected'))
.mockResolvedValueOnce(emptyAlbumResponse)
.mockResolvedValueOnce(emptyAlbumResponse);
const res = await request(app).get(`${SYNO}/albums`).set('Cookie', authCookie(user.id));
// Personal failed (SSRF), shared sources returned an album — 200 with non-empty list.
expect(res.status).toBe(200);
expect(Array.isArray(res.body.albums)).toBe(true);
expect(res.body.albums.length).toBeGreaterThan(0);
});
});
// Was photos.bridge, which existed for consumers outside the container and had
// none left. The repository is what it delegated to. Resolved off the real,
// DI-wired container (`nestApp`, built in the file's own `beforeAll` above)
// rather than hand-constructed — `TrekPhotoRegistrationService` now takes ORM
// repositories, not a bare `DatabaseService` (Plan 3e Task 6). These helpers
// call it directly, with no HTTP request around them (unlike the real
// `syncSynologyAlbum` call chain this pins, which always runs inside one) —
// `withRequestContext` supplies the same per-call EntityManager fork a real
// request's `mikroOrmRequestContext` middleware (bootstrap.ts) would.
let trekPhotos: TrekPhotoRegistrationService;
beforeAll(() => {
trekPhotos = nestApp.get(TrekPhotoRegistrationService);
});
const getOrCreateTrekPhoto = (...a: Parameters<TrekPhotoRegistrationService['getOrCreate']>) =>
withRequestContext(orm, () => trekPhotos.getOrCreate(...a));
const deleteTrekPhotoIfOrphan = (id: number) => withRequestContext(orm, () => trekPhotos.deleteIfOrphan(id));
describe('trek_photos passphrase healing (SYNO-090)', () => {
it('SYNO-090 — getOrCreateTrekPhoto overwrites an existing bad passphrase when a new one is supplied', async () => {
const { user } = createUser(testDb);
const wrongPass = 'wrong-passphrase';
const correctPass = 'correct-passphrase';
const id1 = await getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, wrongPass);
const row1 = await findRow(orm, TrekPhotos, { id: id1 });
expect(decrypt_api_key(String(row1?.passphrase))).toBe(wrongPass);
const id2 = await getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, correctPass);
expect(id2).toBe(id1);
const row2 = await findRow(orm, TrekPhotos, { id: id2 });
expect(decrypt_api_key(String(row2?.passphrase))).toBe(correctPass);
});
});
describe('trek_photos orphan cleanup (SYNO-091)', () => {
it('SYNO-091 — deleteTrekPhotoIfOrphan removes the trek_photos row when no trip_photos or journey_photos reference it', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await updateRows(orm, PhotoProviders, { id: 'synologyphotos' }, { enabled: 1 });
const trekPhotoId = await getOrCreateTrekPhoto('synologyphotos', 'asset-orphan-test', user.id, 'pass-A');
await insertRowIgnoringConflict(orm, TripPhotos, { trip: trip.id, user: user.id, photo: trekPhotoId, shared: 1 });
// Still referenced — must not be deleted.
await deleteTrekPhotoIfOrphan(trekPhotoId);
expect(await findRow(orm, TrekPhotos, { id: trekPhotoId })).not.toBeNull();
// Remove the reference, then orphan-cleanup should delete the trek_photos row.
await deleteRows(orm, TripPhotos, { photo: trekPhotoId });
await deleteTrekPhotoIfOrphan(trekPhotoId);
expect(await findRow(orm, TrekPhotos, { id: trekPhotoId })).toBeNull();
});
it('SYNO-092 — re-adding a previously removed Synology photo stores the new passphrase correctly', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await updateRows(orm, PhotoProviders, { id: 'synologyphotos' }, { enabled: 1 });
const firstPass = 'first-passphrase';
const secondPass = 'second-passphrase';
// Add with wrong passphrase, then remove (simulating the bug scenario).
const id1 = await getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, firstPass);
await insertRowIgnoringConflict(orm, TripPhotos, { trip: trip.id, user: user.id, photo: id1, shared: 1 });
await deleteRows(orm, TripPhotos, { photo: id1 });
await deleteTrekPhotoIfOrphan(id1);
// trek_photos row should be gone.
expect(await findRow(orm, TrekPhotos, { id: id1 })).toBeNull();
// Re-add with the correct passphrase.
const id2 = await getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, secondPass);
const row = await findRow(orm, TrekPhotos, { id: id2 });
expect(decrypt_api_key(String(row?.passphrase))).toBe(secondPass);
});
});
// ── Skip-SSL forwarding on image-byte fetches (#1611) ─────────────────────────
describe('Synology skip-SSL forwarding to image fetches (#1611)', () => {
// Earlier tests queue mock*Once responses on safeFetch that are not always
// fully consumed — reset to the shared fake so they can't leak in here.
beforeEach(async () => {
const guard = (await import('../../src/utils/ssrfGuard')) as any;
vi.mocked(safeFetch).mockReset();
vi.mocked(safeFetch).mockImplementation(guard.__fakeSynologyFetch);
});
// Unique asset id per run — the thumbnail disk cache keys on
// provider:asset:kind:owner and would otherwise serve a stale entry from a
// previous run instead of hitting safeFetch.
let assetSeq = 0;
const uniqueAssetId = () => `${Date.now()}${++assetSeq}_test1611`;
async function createSynologyTrekPhoto(skipSsl: 0 | 1) {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
await updateRows(orm, Users, { id: user.id }, { synology_skip_ssl: skipSsl });
const assetId = uniqueAssetId();
const trekPhotoId = await insertRow(orm, TrekPhotos, {
provider: 'synologyphotos',
asset_id: assetId,
owner: user.id,
});
return { user, trekPhotoId };
}
function thumbnailFetchCalls() {
return vi.mocked(safeFetch).mock.calls.filter((call) => String(call[0]).includes('SYNO.Foto.Thumbnail'));
}
it('SYNO-100 — thumbnail fetch passes rejectUnauthorized: false when skip-SSL is enabled', async () => {
const { user, trekPhotoId } = await createSynologyTrekPhoto(1);
vi.mocked(safeFetch).mockClear();
const res = await request(app).get(`/api/photos/${trekPhotoId}/thumbnail`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
const calls = thumbnailFetchCalls();
expect(calls.length).toBeGreaterThan(0);
for (const call of calls) {
expect(call[2]).toMatchObject({ rejectUnauthorized: false });
}
});
it('SYNO-101 — original fetch passes rejectUnauthorized: false when skip-SSL is enabled', async () => {
const { user, trekPhotoId } = await createSynologyTrekPhoto(1);
vi.mocked(safeFetch).mockClear();
const res = await request(app).get(`/api/photos/${trekPhotoId}/original`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
const calls = thumbnailFetchCalls();
expect(calls.length).toBeGreaterThan(0);
for (const call of calls) {
expect(call[2]).toMatchObject({ rejectUnauthorized: false });
}
});
it('SYNO-102 — image fetches verify TLS when skip-SSL is disabled', async () => {
const { user, trekPhotoId } = await createSynologyTrekPhoto(0);
vi.mocked(safeFetch).mockClear();
const res = await request(app).get(`/api/photos/${trekPhotoId}/thumbnail`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
const calls = thumbnailFetchCalls();
expect(calls.length).toBeGreaterThan(0);
for (const call of calls) {
expect(call[2]?.rejectUnauthorized ?? true).toBe(true);
}
});
});