mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
7871c06059
- Fix endpoint path: users now provide full base URL (e.g. https://nas:5001/photo) - Add OTP/2FA field for Synology login - Add skip SSL verification option (DB column + checkbox UI) - Add device ID (synology_did) column for session tracking - Trigger in-app notification when Synology session is cleared - Show disconnection banner in MemoriesPanel - Add URL hint in provider settings - Map Synology API error codes to human-readable messages - Update i18n for all locales
865 lines
33 KiB
TypeScript
865 lines
33 KiB
TypeScript
/**
|
||
* 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(), broadcastToUser: 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);
|
||
});
|
||
});
|
||
|
||
// ── Album sync ────────────────────────────────────────────────────────────────
|
||
|
||
import { addAlbumLink } from '../helpers/factories';
|
||
|
||
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.
|
||
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
|
||
// 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 = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND user_id = ?').all(trip.id, user.id) as any[];
|
||
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);
|
||
});
|
||
});
|
||
|
||
// ── 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' });
|
||
// Four safeFetch calls: login, failed album list, re-login, successful album list
|
||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4);
|
||
});
|
||
|
||
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' });
|
||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4);
|
||
});
|
||
});
|
||
|
||
// ── 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: {},
|
||
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);
|
||
|
||
// 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));
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|
||
|
||
// ── 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 album list returns 400', async () => {
|
||
const { user } = createUser(testDb);
|
||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||
|
||
const { SsrfBlockedError: SsrfErr } = await import('../../src/utils/ssrfGuard');
|
||
|
||
// Auth succeeds, but the album-list call throws SsrfBlockedError
|
||
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'));
|
||
|
||
const res = await request(app)
|
||
.get(`${SYNO}/albums`)
|
||
.set('Cookie', authCookie(user.id));
|
||
|
||
// _fetchSynologyJson catches SsrfBlockedError and returns fail(message, 400)
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toBeDefined();
|
||
});
|
||
});
|