Files
TREK/server/tests/unit/nest/immich.service.test.ts
T
Maurice c0d90d57b2 refactor(server): fold the memories domain into providers
The seven files in services/memories/ become injectable services. Every SQL
statement, error string, status code and header moves verbatim; what changes is
who owns what.

photoResolverService was two files in one, so it stayed two: the trek_photos
rows are a repository under photos/, the provider dispatch is
PhotoResolverService under memories/. That is the photos/ vs memories/ boundary —
storage there, dispatch here — and it is also why the module graph is a straight
line: TrekPhotosModule is a leaf both halves import, so neither needs a
forwardRef.

pipeAsset and the ServiceResult envelope stay free functions over the Express
response. pipeAsset is deliberately not a StreamableFile: it forwards the
upstream 206 and Content-Range so a <video> can seek (#823), swaps Cache-Control
to no-store on an error status, and answers differently once headers are sent.

The trek-photo cache keeps its in-flight map at module scope. That map is the
stampede guard, and the scheduler builds its own instance through a lazy require
outside the container — a per-instance map would give them separate guards and
ten tiles would fetch the same asset ten times, silently.

journeyService's dynamic import() of synologyService is gone: it existed only to
duck the import cycle, which no longer exists.

MemoriesModule imports RealtimeModule explicitly even though it is @Global, so a
single-domain e2e TestingModule can still resolve the broadcast.

The providers arrived at 60% branch coverage or less, because services/memories/
sits outside the gate and the integration suites drive the domain over HTTP
without reaching the failure paths. New suites cover what had no case at all:
the access checks that decide who may fetch someone else's photo, the stampede
guard, the thumbnail mtime check, the poster-less-video 404, and Immich end to
end with safeFetch stubbed — including the hidden-asset filter older servers
depend on (#1474) and the http -> https canonical detection that must NOT
rewrite a changed host.
2026-08-08 22:27:56 +02:00

434 lines
19 KiB
TypeScript

/**
* ImmichService — credentials, settings, connection, browsing and the asset
* proxy.
*
* The provider code came out of services/memories/ at ~61% branch coverage,
* because that tree is outside the gate: the integration suites drive it over
* HTTP and never reach most of the failure paths. These cases go at the service
* directly with safeFetch stubbed, so every "upstream said no" branch is pinned.
*/
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 } = vi.hoisted(() => ({ safeFetch: vi.fn(), checkSsrf: vi.fn() }));
vi.mock('../../../src/utils/ssrfGuard', () => ({
safeFetch,
checkSsrf,
createPinnedDispatcher: vi.fn(() => ({})),
SsrfBlockedError: class extends Error {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { ImmichService } from '../../../src/nest/memories/immich.service';
import type { AuditService } from '../../../src/nest/audit/audit.service';
import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
const audit = { writeAudit: vi.fn() };
const access = { getAlbumIdFromLink: vi.fn() };
const dbs = new DatabaseService(testDb);
const svc = new ImmichService(dbs, audit as unknown as AuditService, access as unknown as MemoriesAccessService);
const USER = 1;
function seedUser(id: number, url: string | null, key: string | null, autoUpload = 0): void {
testDb.prepare("INSERT OR REPLACE INTO users (id, username, email, password_hash, immich_url, immich_api_key, immich_auto_upload) VALUES (?, ?, ?, 'x', ?, ?, ?)")
.run(id, `u${id}`, `u${id}@example.test`, url, key, autoUpload);
}
/** A Response-ish object with only what the service reads. */
function upstream(opts: { ok?: boolean; status?: number; json?: unknown; url?: string; contentType?: string | null; body?: string }) {
return {
ok: opts.ok ?? true,
status: opts.status ?? 200,
url: opts.url ?? '',
headers: { get: (h: string) => (h === 'content-type' ? (opts.contentType ?? 'image/jpeg') : null) },
json: async () => opts.json ?? {},
arrayBuffer: async () => Buffer.from(opts.body ?? 'bytes'),
};
}
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, 'https://immich.test', 'key-1');
});
afterAll(() => testDb.close());
describe('getImmichCredentials', () => {
it('IMMICH-001: returns null when the user row is missing', () => {
expect(svc.getImmichCredentials(999)).toBeNull();
});
it('IMMICH-002: returns null without a URL', () => {
seedUser(2, null, 'key');
expect(svc.getImmichCredentials(2)).toBeNull();
});
it('IMMICH-003: returns null without an API key', () => {
seedUser(3, 'https://immich.test', null);
expect(svc.getImmichCredentials(3)).toBeNull();
});
it('IMMICH-004: returns null when the stored key cannot be decrypted', () => {
decryptMock.mockReturnValue(null);
expect(svc.getImmichCredentials(USER)).toBeNull();
});
it('IMMICH-005: returns the decrypted pair otherwise', () => {
expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://immich.test', immich_api_key: 'key-1' });
});
});
describe('isValidAssetId', () => {
it('IMMICH-006: accepts UUID-ish ids and rejects traversal or over-long input', () => {
expect(svc.isValidAssetId('abc-123_XYZ')).toBe(true);
expect(svc.isValidAssetId('../etc/passwd')).toBe(false);
expect(svc.isValidAssetId('a'.repeat(101))).toBe(false);
});
});
describe('getConnectionSettings / setImmichAutoUpload', () => {
it('IMMICH-007: reports connected with the URL when configured', () => {
expect(svc.getConnectionSettings(USER)).toEqual({ immich_url: 'https://immich.test', connected: true, auto_upload: false });
});
it('IMMICH-008: reports an empty URL and not connected when it is not', () => {
seedUser(4, null, null);
expect(svc.getConnectionSettings(4)).toEqual({ immich_url: '', connected: false, auto_upload: false });
});
it('IMMICH-009: surfaces the auto-upload flag both ways', () => {
svc.setImmichAutoUpload(USER, true);
expect(svc.getConnectionSettings(USER).auto_upload).toBe(true);
svc.setImmichAutoUpload(USER, false);
expect(svc.getConnectionSettings(USER).auto_upload).toBe(false);
});
});
describe('saveImmichSettings', () => {
it('IMMICH-010: refuses a URL the SSRF guard blocks, and writes nothing', async () => {
checkSsrf.mockResolvedValue({ allowed: false, error: 'blocked host' });
const result = await svc.saveImmichSettings(USER, 'http://169.254.169.254', 'k', null);
expect(result).toEqual({ success: false, error: 'Invalid Immich URL: blocked host' });
expect(svc.getImmichCredentials(USER)!.immich_url).toBe('https://immich.test');
});
it('IMMICH-011: stores a trimmed URL and reports plain success', async () => {
const result = await svc.saveImmichSettings(USER, ' https://new.test ', 'k2', null);
expect(result).toEqual({ success: true });
expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://new.test', immich_api_key: 'k2' });
});
it('IMMICH-012: warns and audits when the URL resolves to a private IP', async () => {
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: true, resolvedIp: '192.168.1.9' });
const result = await svc.saveImmichSettings(USER, 'http://nas.local', 'k', '5.6.7.8');
expect(result.success).toBe(true);
expect(result.warning).toContain('192.168.1.9');
expect(audit.writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'immich.private_ip_configured', ip: '5.6.7.8' }));
});
it('IMMICH-013: an empty URL clears the connection without an SSRF check', async () => {
const result = await svc.saveImmichSettings(USER, undefined, undefined, null);
expect(result).toEqual({ success: true });
expect(checkSsrf).not.toHaveBeenCalled();
expect(svc.getImmichCredentials(USER)).toBeNull();
});
});
describe('testConnection', () => {
it('IMMICH-014: refuses a blocked URL before any request', async () => {
checkSsrf.mockResolvedValue({ allowed: false, error: 'nope' });
expect(await svc.testConnection('http://x', 'k')).toEqual({ connected: false, error: 'nope' });
expect(safeFetch).not.toHaveBeenCalled();
});
it('IMMICH-015: falls back to a generic message when the guard gives no reason', async () => {
checkSsrf.mockResolvedValue({ allowed: false });
expect(await svc.testConnection('http://x', 'k')).toEqual({ connected: false, error: 'Invalid Immich URL' });
});
it('IMMICH-016: reports the HTTP status when the server rejects the key', async () => {
safeFetch.mockResolvedValue(upstream({ ok: false, status: 401 }));
expect(await svc.testConnection('https://immich.test', 'bad')).toEqual({ connected: false, error: 'HTTP 401' });
});
it('IMMICH-017: returns the user on success', async () => {
safeFetch.mockResolvedValue(upstream({ json: { name: 'Ada', email: 'ada@example.test' } }));
const result = await svc.testConnection('https://immich.test', 'k');
expect(result.connected).toBe(true);
expect(result.user).toEqual({ name: 'Ada', email: 'ada@example.test' });
});
it('IMMICH-018: reports the canonical URL after an http → https upgrade', async () => {
safeFetch.mockResolvedValue(upstream({ json: {}, url: 'https://immich.test/api/users/me' }));
const result = await svc.testConnection('http://immich.test', 'k');
expect(result.canonicalUrl).toBe('https://immich.test');
});
it('IMMICH-019: does NOT rewrite the URL when the host changed — that is a redirect elsewhere', async () => {
safeFetch.mockResolvedValue(upstream({ json: {}, url: 'https://evil.test/api/users/me' }));
const result = await svc.testConnection('http://immich.test', 'k');
expect(result.canonicalUrl).toBeUndefined();
});
it('IMMICH-020: reports the thrown message when the request fails', async () => {
safeFetch.mockRejectedValue(new Error('ECONNREFUSED'));
expect(await svc.testConnection('https://immich.test', 'k')).toEqual({ connected: false, error: 'ECONNREFUSED' });
});
it('IMMICH-021: falls back to "Connection failed" for a non-Error rejection', async () => {
safeFetch.mockRejectedValue('boom');
expect(await svc.testConnection('https://immich.test', 'k')).toEqual({ connected: false, error: 'Connection failed' });
});
});
describe('getConnectionStatus', () => {
it('IMMICH-022: says "Not configured" without credentials', async () => {
seedUser(5, null, null);
expect(await svc.getConnectionStatus(5)).toEqual({ connected: false, error: 'Not configured' });
});
it('IMMICH-023: reports the HTTP status, the user, and a thrown failure', async () => {
safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 500 }));
expect(await svc.getConnectionStatus(USER)).toEqual({ connected: false, error: 'HTTP 500' });
safeFetch.mockResolvedValueOnce(upstream({ json: { name: 'Ada' } }));
expect((await svc.getConnectionStatus(USER)).user).toEqual({ name: 'Ada', email: undefined });
safeFetch.mockRejectedValueOnce(new Error('timeout'));
expect(await svc.getConnectionStatus(USER)).toEqual({ connected: false, error: 'timeout' });
});
});
describe('browseTimeline', () => {
it('IMMICH-024: 400s without credentials', async () => {
seedUser(6, null, null);
expect(await svc.browseTimeline(6)).toEqual({ error: 'Immich not configured', status: 400 });
});
it('IMMICH-025: forwards the upstream status on failure and the buckets on success', async () => {
safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 503 }));
expect(await svc.browseTimeline(USER)).toEqual({ error: 'Failed to fetch from Immich', status: 503 });
safeFetch.mockResolvedValueOnce(upstream({ json: [{ count: 3 }] }));
expect(await svc.browseTimeline(USER)).toEqual({ buckets: [{ count: 3 }] });
});
it('IMMICH-026: maps an unreachable server to 502', async () => {
safeFetch.mockRejectedValue(new Error('down'));
expect(await svc.browseTimeline(USER)).toEqual({ error: 'Could not reach Immich', status: 502 });
});
});
describe('searchPhotos', () => {
it('IMMICH-027: 400s without credentials and 502s when the server is unreachable', async () => {
seedUser(7, null, null);
expect(await svc.searchPhotos(7)).toEqual({ error: 'Immich not configured', status: 400 });
safeFetch.mockRejectedValue(new Error('down'));
expect(await svc.searchPhotos(USER)).toEqual({ error: 'Could not reach Immich', status: 502 });
});
it('IMMICH-028: forwards the upstream status on a failed search', async () => {
safeFetch.mockResolvedValue(upstream({ ok: false, status: 400 }));
expect(await svc.searchPhotos(USER)).toEqual({ error: 'Search failed', status: 400 });
});
it('IMMICH-029: drops hidden assets an older server still returns (#1474)', async () => {
safeFetch.mockResolvedValue(upstream({
json: { assets: { items: [
{ id: 'ok', fileCreatedAt: '2026-01-01' },
{ id: 'hidden-flag', visibility: 'hidden' },
{ id: 'legacy-flag', isVisible: false },
] } },
}));
const result = await svc.searchPhotos(USER);
expect(result.assets!.map(a => a.id)).toEqual(['ok']);
});
it('IMMICH-030: maps exif fields, falls back to createdAt, and marks videos', async () => {
safeFetch.mockResolvedValue(upstream({
json: { assets: { items: [
{ id: 'a', createdAt: '2026-02-02', type: 'VIDEO', exifInfo: { city: 'Kyoto', country: 'JP', latitude: 35.0, longitude: 135.7 } },
{ id: 'b', fileCreatedAt: '2026-03-03', type: 'IMAGE', exifInfo: { latitude: 'nope' } },
] } },
}));
const [a, b] = (await svc.searchPhotos(USER)).assets!;
expect(a).toMatchObject({ takenAt: '2026-02-02', city: 'Kyoto', country: 'JP', lat: 35.0, lng: 135.7, mediaType: 'video' });
// A non-numeric coordinate becomes null rather than reaching the client as a string.
expect(b).toMatchObject({ takenAt: '2026-03-03', city: null, country: null, lat: null, lng: null, mediaType: 'image' });
});
it('IMMICH-031: hasMore counts the raw page, not the filtered one', async () => {
// Otherwise a page that is entirely hidden assets would stop pagination dead.
safeFetch.mockResolvedValue(upstream({
json: { assets: { items: [{ id: 'x', visibility: 'hidden' }, { id: 'y', visibility: 'hidden' }] } },
}));
const result = await svc.searchPhotos(USER, undefined, undefined, 1, 2);
expect(result.assets).toEqual([]);
expect(result.hasMore).toBe(true);
});
it('IMMICH-032: turns a from/to pair into the upstream date bounds', async () => {
safeFetch.mockResolvedValue(upstream({ json: { assets: { items: [] } } }));
await svc.searchPhotos(USER, '2026-01-01', '2026-01-31');
const body = JSON.parse((safeFetch.mock.calls[0][1] as { body: string }).body);
expect(body.takenAfter).toBe('2026-01-01T00:00:00.000Z');
expect(body.takenBefore).toBe('2026-01-31T23:59:59.999Z');
// Load-bearing on Immich >= 1.133: hidden assets must not cross the wire.
expect(body.visibility).toBe('timeline');
});
});
describe('getAssetInfo', () => {
it('IMMICH-033: 404s when the owner has no credentials', async () => {
seedUser(8, null, null);
expect(await svc.getAssetInfo(USER, 'a1', 8)).toEqual({ error: 'Not found', status: 404 });
});
it('IMMICH-034: reads the OWNER\'s credentials, not the requester\'s', async () => {
seedUser(9, 'https://owner.test', 'owner-key');
safeFetch.mockResolvedValue(upstream({ json: { id: 'a1' } }));
await svc.getAssetInfo(USER, 'a1', 9);
expect(safeFetch.mock.calls[0][0]).toContain('https://owner.test');
expect((safeFetch.mock.calls[0][1] as { headers: Record<string, string> }).headers['x-api-key']).toBe('owner-key');
});
it('IMMICH-035: forwards the upstream status and maps a proxy failure to 502', async () => {
safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 404 }));
expect(await svc.getAssetInfo(USER, 'a1')).toEqual({ error: 'Failed', status: 404 });
safeFetch.mockRejectedValueOnce(new Error('down'));
expect(await svc.getAssetInfo(USER, 'a1')).toEqual({ error: 'Proxy error', status: 502 });
});
it('IMMICH-036: composes camera/focal/aperture only when both halves are present', async () => {
safeFetch.mockResolvedValueOnce(upstream({
json: { id: 'a1', fileCreatedAt: 'x', originalFileName: 'IMG.jpg', exifInfo: { make: 'Fuji', model: 'X100V', focalLength: 23, fNumber: 2, iso: 200 } },
}));
const full = (await svc.getAssetInfo(USER, 'a1')).data;
expect(full).toMatchObject({ camera: 'Fuji X100V', focalLength: '23mm', aperture: 'f/2', iso: 200, fileName: 'IMG.jpg' });
safeFetch.mockResolvedValueOnce(upstream({ json: { id: 'a2', exifInfo: { make: 'Fuji' } } }));
const partial = (await svc.getAssetInfo(USER, 'a2')).data;
expect(partial.camera).toBeNull();
expect(partial.focalLength).toBeNull();
});
it('IMMICH-037: survives an asset with no exif block at all', async () => {
safeFetch.mockResolvedValue(upstream({ json: { id: 'bare' } }));
const data = (await svc.getAssetInfo(USER, 'bare')).data;
expect(data).toMatchObject({ id: 'bare', width: null, height: null, city: null, fileName: null });
});
});
describe('fetchImmichThumbnailBytes', () => {
it('IMMICH-038: 404s without credentials for the owner', async () => {
seedUser(10, null, null);
expect(await svc.fetchImmichThumbnailBytes(USER, 'a1', 10)).toEqual({ error: 'Not found', status: 404 });
});
it('IMMICH-039: returns the bytes with the upstream content type', async () => {
safeFetch.mockResolvedValue(upstream({ contentType: 'image/webp', body: 'thumb' }));
const result = await svc.fetchImmichThumbnailBytes(USER, 'a1');
expect(result).toEqual({ bytes: Buffer.from('thumb'), contentType: 'image/webp' });
});
it('IMMICH-040: defaults the content type when the server omits it', async () => {
safeFetch.mockResolvedValue(upstream({ contentType: null, body: 'thumb' }));
const result = await svc.fetchImmichThumbnailBytes(USER, 'a1') as { contentType: string };
expect(result.contentType).toBe('image/jpeg');
});
it('IMMICH-041: forwards the upstream status and maps a throw to 502', async () => {
safeFetch.mockResolvedValueOnce(upstream({ ok: false, status: 403 }));
expect(await svc.fetchImmichThumbnailBytes(USER, 'a1')).toEqual({ error: 'Upstream error', status: 403 });
safeFetch.mockRejectedValueOnce(new Error('down'));
expect(await svc.fetchImmichThumbnailBytes(USER, 'a1')).toEqual({ error: 'Proxy error', status: 502 });
});
});
describe('collectAlbumSelection', () => {
it('IMMICH-042: 404s when the album link does not resolve', async () => {
access.getAlbumIdFromLink.mockReturnValue({ success: false });
expect(await svc.collectAlbumSelection('1', 'l1', USER)).toEqual({ error: 'Album link not found', status: 404 });
});
it('IMMICH-043: 400s when the user has no Immich credentials', async () => {
access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' });
seedUser(11, null, null);
expect(await svc.collectAlbumSelection('1', 'l1', 11)).toEqual({ error: 'Immich not configured', status: 400 });
});
it('IMMICH-044: returns only visible image assets, with the raw total', async () => {
access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' });
safeFetch.mockResolvedValue(upstream({
json: { assets: [
{ id: 'img', type: 'IMAGE' },
{ id: 'vid', type: 'VIDEO' },
{ id: 'hidden', type: 'IMAGE', visibility: 'hidden' },
] },
}));
const result = await svc.collectAlbumSelection('1', 'l1', USER) as { selection: { asset_ids: string[] }; total: number };
expect(result.selection.asset_ids).toEqual(['img']);
expect(result.total).toBe(1);
});
it('IMMICH-045: maps an unreachable server to 502', async () => {
access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' });
safeFetch.mockRejectedValue(new Error('down'));
expect(await svc.collectAlbumSelection('1', 'l1', USER)).toEqual({ error: 'Could not reach Immich', status: 502 });
});
});