mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
test(server): cover the Synology provider
Completes the pass over the providers that moved into the gated tree. The session handling is the part that most needed it: Synology invalidates a SID on timeout (106), on a duplicate login (107) and when it does not recognise it (119), and the service is supposed to clear it, re-login once and repeat the call. That retry had no case at all — a regression there would have looked like "Synology randomly stops working" and nothing would have failed. Also pinned: a SID that no longer decrypts after a key rotation triggers a fresh login rather than a dead session; a stored device id rides along so a trusted device skips OTP; an app-level error that is not a session code is NOT retried; the album listing merges personal, shared and shared-with-me, survives a partial source failure, and collapses an id that appears in two sources; and an SSRF block stays a 400 with the guard's own message while any other transport failure is a 500.
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* SynologyService — credentials, the SID session with its retry, album listing
|
||||
* across three sources, and the asset paths.
|
||||
*
|
||||
* Same gap as Immich: the provider arrived from services/memories/ at ~60%
|
||||
* branch coverage because that tree is outside the gate, and the integration
|
||||
* suite drives it over HTTP without reaching the failure paths. The session
|
||||
* retry in particular — Synology invalidates a SID on timeout (106), a
|
||||
* duplicate login (107) or an unknown SID (119), and the service is supposed to
|
||||
* re-login once and repeat the call — had no case at all.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
return {
|
||||
testDb: db,
|
||||
dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null, isOwner: () => false, getPlaceWithTags: () => null },
|
||||
};
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'x'.repeat(40),
|
||||
ENCRYPTION_KEY: 'a'.repeat(64),
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { decryptMock } = vi.hoisted(() => ({ decryptMock: vi.fn((v: string) => v) }));
|
||||
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
||||
decrypt_api_key: decryptMock,
|
||||
encrypt_api_key: (v: string) => v,
|
||||
maybe_encrypt_api_key: (v: string) => v,
|
||||
}));
|
||||
|
||||
const { safeFetch, checkSsrf, SsrfBlockedError } = vi.hoisted(() => {
|
||||
class SsrfBlockedError extends Error {}
|
||||
return { safeFetch: vi.fn(), checkSsrf: vi.fn(), SsrfBlockedError };
|
||||
});
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
safeFetch, checkSsrf, SsrfBlockedError, createPinnedDispatcher: vi.fn(() => ({})),
|
||||
}));
|
||||
// The service fires the session-cleared notice and .catch()es it, so the stub
|
||||
// has to be a promise.
|
||||
vi.mock('../../../src/nest/notifications/notifications.bridge', () => ({ send: vi.fn(async () => undefined) }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { SynologyService } from '../../../src/nest/memories/synology.service';
|
||||
import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
|
||||
|
||||
const access = { getAlbumLinkForSync: vi.fn(), updateSyncTimeForAlbumLink: vi.fn() };
|
||||
const svc = new SynologyService(new DatabaseService(testDb), access as unknown as MemoriesAccessService);
|
||||
|
||||
const USER = 1;
|
||||
|
||||
function seedUser(id: number, cols: Partial<Record<string, unknown>> = {}): void {
|
||||
const base = { synology_url: 'https://nas.test', synology_username: 'ada', synology_password: 'pw', synology_sid: 'sid-1', synology_did: null, synology_skip_ssl: 1 };
|
||||
const row = { ...base, ...cols };
|
||||
testDb.prepare(
|
||||
`INSERT OR REPLACE INTO users (id, username, email, password_hash, synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl)
|
||||
VALUES (?, ?, ?, 'x', ?, ?, ?, ?, ?, ?)`
|
||||
).run(id, `u${id}`, `u${id}@example.test`, row.synology_url, row.synology_username, row.synology_password, row.synology_sid, row.synology_did, row.synology_skip_ssl);
|
||||
}
|
||||
|
||||
/** A Synology API envelope: { success, data } or { success:false, error:{ code } }. */
|
||||
function api(data: unknown) {
|
||||
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data }), arrayBuffer: async () => Buffer.from('x') };
|
||||
}
|
||||
function apiError(code: number) {
|
||||
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code } }), arrayBuffer: async () => Buffer.from('x') };
|
||||
}
|
||||
function httpError(status: number) {
|
||||
return { ok: false, status, headers: { get: () => null }, json: async () => ({}), arrayBuffer: async () => Buffer.from('x') };
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
decryptMock.mockImplementation((v: string) => v);
|
||||
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
|
||||
testDb.prepare('DELETE FROM users').run();
|
||||
seedUser(USER);
|
||||
});
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
describe('credentials', () => {
|
||||
it('SYNO-U001: an unknown user is 404, not "not configured"', async () => {
|
||||
const result = await svc.getSynologySettings(999);
|
||||
expect(result).toEqual({ success: false, error: { message: 'User not found', status: 404 } });
|
||||
});
|
||||
|
||||
it('SYNO-U002: a half-filled row is "Synology not configured"', async () => {
|
||||
seedUser(2, { synology_password: null });
|
||||
const result = await svc.getSynologySettings(2);
|
||||
expect(result).toEqual({ success: false, error: { message: 'Synology not configured', status: 400 } });
|
||||
});
|
||||
|
||||
it('SYNO-U003: a password that will not decrypt is a 500, not a silent retry', async () => {
|
||||
decryptMock.mockReturnValue(null);
|
||||
const result = await svc.getSynologySettings(USER);
|
||||
expect(result).toEqual({ success: false, error: { message: 'Synology credentials corrupted', status: 500 } });
|
||||
});
|
||||
|
||||
it('SYNO-U004: skip_ssl is read as a boolean, 0 meaning verify', async () => {
|
||||
seedUser(3, { synology_skip_ssl: 0 });
|
||||
const result = await svc.getSynologySettings(3);
|
||||
expect(result.success && result.data.synology_skip_ssl).toBe(false);
|
||||
});
|
||||
|
||||
it('SYNO-U005: settings report the URL, the username and a live session', async () => {
|
||||
const result = await svc.getSynologySettings(USER);
|
||||
expect(result.success && result.data).toMatchObject({ synology_url: 'https://nas.test', synology_username: 'ada', connected: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the session', () => {
|
||||
it('SYNO-U010: a cached SID is used without logging in', async () => {
|
||||
safeFetch.mockResolvedValue(api({ list: [] }));
|
||||
await svc.searchSynologyPhotos(USER);
|
||||
const bodies = safeFetch.mock.calls.map(c => String((c[1] as { body: URLSearchParams }).body));
|
||||
expect(bodies.some(b => b.includes('SYNO.API.Auth'))).toBe(false);
|
||||
});
|
||||
|
||||
it('SYNO-U011: a SID that will not decrypt is cleared and a fresh login happens', async () => {
|
||||
decryptMock.mockImplementation((v: string) => (v === 'sid-1' ? null : v));
|
||||
safeFetch.mockResolvedValueOnce(api({ sid: 'sid-2' })).mockResolvedValueOnce(api({ list: [] }));
|
||||
|
||||
await svc.searchSynologyPhotos(USER);
|
||||
|
||||
const first = String((safeFetch.mock.calls[0][1] as { body: URLSearchParams }).body);
|
||||
expect(first).toContain('SYNO.API.Auth');
|
||||
});
|
||||
|
||||
it('SYNO-U012: a login that returns no sid is a 500', async () => {
|
||||
seedUser(4, { synology_sid: null });
|
||||
safeFetch.mockResolvedValue(api({}));
|
||||
const result = await svc.searchSynologyPhotos(4);
|
||||
expect(result).toEqual({ success: false, error: { message: 'Failed to get session ID from Synology', status: 500 } });
|
||||
});
|
||||
|
||||
it('SYNO-U013: a stored device id rides along so a trusted device skips OTP', async () => {
|
||||
seedUser(5, { synology_sid: null, synology_did: 'device-1' });
|
||||
safeFetch.mockResolvedValueOnce(api({ sid: 's' })).mockResolvedValueOnce(api({ list: [] }));
|
||||
|
||||
await svc.searchSynologyPhotos(5);
|
||||
|
||||
expect(String((safeFetch.mock.calls[0][1] as { body: URLSearchParams }).body)).toContain('device_id=device-1');
|
||||
});
|
||||
|
||||
it.each([106, 107, 119])('SYNO-U014: error %i clears the SID, re-logs in and repeats the call', async (code) => {
|
||||
safeFetch
|
||||
.mockResolvedValueOnce(apiError(code)) // the call, with a dead SID
|
||||
.mockResolvedValueOnce(api({ sid: 'sid-2' })) // the re-login
|
||||
.mockResolvedValueOnce(api({ list: [] })); // the retry
|
||||
|
||||
const result = await svc.searchSynologyPhotos(USER);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(String((safeFetch.mock.calls[1][1] as { body: URLSearchParams }).body)).toContain('SYNO.API.Auth');
|
||||
});
|
||||
|
||||
it('SYNO-U015: an app-level error that is NOT a session code is not retried', async () => {
|
||||
safeFetch.mockResolvedValue(apiError(105));
|
||||
const result = await svc.searchSynologyPhotos(USER);
|
||||
expect(result.success).toBe(false);
|
||||
// one call only — no re-login, no repeat
|
||||
expect(safeFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the API envelope', () => {
|
||||
it('SYNO-U020: an HTTP failure carries the upstream status', async () => {
|
||||
safeFetch.mockResolvedValue(httpError(502));
|
||||
const result = await svc.searchSynologyPhotos(USER);
|
||||
expect(result).toEqual({ success: false, error: { message: 'Synology API request failed with status 502', status: 502 } });
|
||||
});
|
||||
|
||||
it('SYNO-U021: a known app error code becomes its documented message at HTTP 400', async () => {
|
||||
safeFetch.mockResolvedValue(apiError(400));
|
||||
const result = await svc.searchSynologyPhotos(USER);
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as { error: { status: number } }).error.status).toBe(400);
|
||||
});
|
||||
|
||||
it('SYNO-U022: an unknown code still produces a message rather than undefined', async () => {
|
||||
safeFetch.mockResolvedValue(apiError(99999));
|
||||
const result = await svc.searchSynologyPhotos(USER);
|
||||
expect((result as { error: { message: string } }).error.message).toContain('99999');
|
||||
});
|
||||
|
||||
it('SYNO-U023: an SSRF block is a 400 with the guard\'s own message', async () => {
|
||||
safeFetch.mockRejectedValue(new SsrfBlockedError('blocked host'));
|
||||
const result = await svc.searchSynologyPhotos(USER);
|
||||
expect(result).toEqual({ success: false, error: { message: 'blocked host', status: 400 } });
|
||||
});
|
||||
|
||||
it('SYNO-U024: any other transport failure is a 500', async () => {
|
||||
safeFetch.mockRejectedValue(new Error('ECONNRESET'));
|
||||
const result = await svc.searchSynologyPhotos(USER);
|
||||
expect(result).toEqual({ success: false, error: { message: 'Failed to connect to Synology API', status: 500 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSynologySettings', () => {
|
||||
it('SYNO-U030: refuses a URL the SSRF guard blocks', async () => {
|
||||
checkSsrf.mockResolvedValue({ allowed: false, error: 'private range' });
|
||||
const result = await svc.updateSynologySettings(USER, 'http://10.0.0.1', 'ada', 'pw');
|
||||
expect(result).toEqual({ success: false, error: { message: 'private range', status: 400 } });
|
||||
});
|
||||
|
||||
it('SYNO-U031: keeps the stored password when none is supplied', async () => {
|
||||
safeFetch.mockResolvedValue(api({ sid: 's' }));
|
||||
await svc.updateSynologySettings(USER, 'https://nas2.test', 'ada');
|
||||
const row = testDb.prepare('SELECT synology_password, synology_url FROM users WHERE id = ?').get(USER) as { synology_password: string; synology_url: string };
|
||||
expect(row.synology_password).toBe('pw');
|
||||
expect(row.synology_url).toBe('https://nas2.test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('testSynologyConnection', () => {
|
||||
it('SYNO-U040: sends the OTP and asks for a device token when one is given', async () => {
|
||||
safeFetch.mockResolvedValue(api({ sid: 's', did: 'd' }));
|
||||
|
||||
await svc.testSynologyConnection(USER, 'https://nas.test', 'ada', 'pw', '123456');
|
||||
|
||||
const body = String((safeFetch.mock.calls[0][1] as { body: URLSearchParams }).body);
|
||||
expect(body).toContain('otp_code=123456');
|
||||
expect(body).toContain('enable_device_token=yes');
|
||||
});
|
||||
|
||||
it('SYNO-U041: omits the OTP fields when none is given', async () => {
|
||||
safeFetch.mockResolvedValue(api({ sid: 's' }));
|
||||
|
||||
await svc.testSynologyConnection(USER, 'https://nas.test', 'ada', 'pw');
|
||||
|
||||
expect(String((safeFetch.mock.calls[0][1] as { body: URLSearchParams }).body)).not.toContain('otp_code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSynologyAlbums', () => {
|
||||
it('SYNO-U050: merges personal, shared and shared-with-me, carrying each passphrase', async () => {
|
||||
safeFetch
|
||||
.mockResolvedValueOnce(api({ list: [{ id: 1, name: 'Personal', item_count: 2 }] }))
|
||||
.mockResolvedValueOnce(api({ list: [{ id: 2, name: 'Shared', item_count: 1, passphrase: 'p2' }] }))
|
||||
.mockResolvedValueOnce(api({ list: [{ id: 3, name: 'WithMe', item_count: 3, sharing_info: { passphrase: 'p3' } }] }));
|
||||
|
||||
const result = await svc.listSynologyAlbums(USER);
|
||||
|
||||
const albums = (result as { data: { albums: { id: string; passphrase?: string }[] } }).data.albums;
|
||||
expect(albums.map(a => a.id).sort()).toEqual(['1', '2', '3']);
|
||||
expect(albums.find(a => a.id === '2')!.passphrase).toBe('p2');
|
||||
expect(albums.find(a => a.id === '3')!.passphrase).toBe('p3');
|
||||
});
|
||||
|
||||
it('SYNO-U051: a partial source failure still returns the albums that came back', async () => {
|
||||
safeFetch
|
||||
.mockResolvedValueOnce(api({ list: [{ id: 1, name: 'Personal' }] }))
|
||||
.mockResolvedValueOnce(apiError(105))
|
||||
.mockResolvedValueOnce(apiError(105));
|
||||
|
||||
const result = await svc.listSynologyAlbums(USER);
|
||||
|
||||
expect((result as { data: { albums: unknown[] } }).data.albums).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('SYNO-U052: the same album id from two sources collapses to one entry', async () => {
|
||||
safeFetch
|
||||
.mockResolvedValueOnce(api({ list: [{ id: 9, name: 'Dup' }] }))
|
||||
.mockResolvedValueOnce(api({ list: [] }))
|
||||
.mockResolvedValueOnce(api({ list: [{ id: 9, name: 'Dup', sharing_info: { passphrase: 'p9' } }] }));
|
||||
|
||||
const result = await svc.listSynologyAlbums(USER);
|
||||
|
||||
const albums = (result as { data: { albums: { id: string; passphrase?: string }[] } }).data.albums;
|
||||
expect(albums).toHaveLength(1);
|
||||
// last write wins, so the shared-with-me passphrase survives
|
||||
expect(albums[0].passphrase).toBe('p9');
|
||||
});
|
||||
|
||||
it('SYNO-U053: when every source fails, the personal failure is surfaced', async () => {
|
||||
safeFetch.mockResolvedValue(httpError(500));
|
||||
const result = await svc.listSynologyAlbums(USER);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSynologyAlbumPhotos', () => {
|
||||
it('SYNO-U060: pages until a short page and keys assets by the thumbnail cache key', async () => {
|
||||
const page = (n: number) => api({ list: Array.from({ length: n }, (_, i) => ({ id: i, time: 1700000000, additional: { thumbnail: { cache_key: `ck-${i}` } } })) });
|
||||
safeFetch.mockResolvedValueOnce(page(50)).mockResolvedValueOnce(page(3));
|
||||
|
||||
const result = await svc.getSynologyAlbumPhotos(USER, '7');
|
||||
|
||||
const assets = (result as { data: { assets: { id: string }[] } }).data.assets;
|
||||
expect(assets).toHaveLength(53);
|
||||
expect(assets[0].id).toBe('ck-0');
|
||||
});
|
||||
|
||||
it('SYNO-U061: a passphrase album queries by passphrase, not album_id', async () => {
|
||||
safeFetch.mockResolvedValue(api({ list: [] }));
|
||||
|
||||
await svc.getSynologyAlbumPhotos(USER, '7', 'secret');
|
||||
|
||||
const body = String((safeFetch.mock.calls[0][1] as { body: URLSearchParams }).body);
|
||||
expect(body).toContain('passphrase=secret');
|
||||
expect(body).not.toContain('album_id');
|
||||
});
|
||||
|
||||
it('SYNO-U062: an upstream failure is passed straight through', async () => {
|
||||
safeFetch.mockResolvedValue(httpError(503));
|
||||
expect((await svc.getSynologyAlbumPhotos(USER, '7')).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectSynologyAlbumSelection', () => {
|
||||
it('SYNO-U070: fails when the album link does not resolve', async () => {
|
||||
access.getAlbumLinkForSync.mockReturnValue({ success: false, error: { message: 'Album link not found', status: 404 } });
|
||||
const result = await svc.collectSynologyAlbumSelection(USER, '1', 'l1');
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('SYNO-U071: returns the cache keys as the selection, with the raw total', async () => {
|
||||
access.getAlbumLinkForSync.mockReturnValue({ success: true, data: { albumId: '7', passphrase: undefined } });
|
||||
safeFetch.mockResolvedValue(api({ list: [
|
||||
{ id: 1, additional: { thumbnail: { cache_key: 'ck-1' } } },
|
||||
{ id: 2, additional: { thumbnail: { cache_key: '' } } },
|
||||
] }));
|
||||
|
||||
const result = await svc.collectSynologyAlbumSelection(USER, '1', 'l1');
|
||||
|
||||
const data = (result as { data: { selection: { asset_ids: string[] }; total: number } }).data;
|
||||
// the empty cache key is filtered out of the selection but still counted
|
||||
expect(data.selection.asset_ids).toEqual(['ck-1']);
|
||||
expect(data.total).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchSynologyThumbnailBytes', () => {
|
||||
it('SYNO-U080: fails without credentials rather than fetching', async () => {
|
||||
seedUser(6, { synology_url: null });
|
||||
const result = await svc.fetchSynologyThumbnailBytes(6, 6, 'a1');
|
||||
expect(result).toHaveProperty('error');
|
||||
expect(safeFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user