mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
724c88c201
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.
1249 lines
51 KiB
TypeScript
1249 lines
51 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 { db as testDb } from '../../../src/db/database';
|
|
import { Users } from '../../../src/db/entities/Users.entity';
|
|
import type { AuditService } from '../../../src/nest/audit/audit.service';
|
|
import { ImmichService } from '../../../src/nest/memories/immich.service';
|
|
import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
|
|
import { deleteRows, upsertRow } from '../../helpers/factories/rows';
|
|
import { readUser } from '../../helpers/factories/users';
|
|
import { makeStorageFixture } from '../../helpers/storage-fixture';
|
|
import { createTestUnitOfWork, createTestUserImmichRepo, sharedTestOrm } from '../../helpers/test-uow';
|
|
import { PROVIDER_SELECT_ALL_MAX_PAGES } from '@trek/shared';
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
vi.mock('../../../src/db/database', async () => {
|
|
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
|
|
const db = createSnapshotTestDb();
|
|
return {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
canAccessTrip: () => null,
|
|
isOwner: () => false,
|
|
getPlaceWithTags: () => null,
|
|
};
|
|
});
|
|
|
|
const { decryptMock, maybeEncryptMock } = vi.hoisted(() => ({
|
|
decryptMock: vi.fn((v: string) => v),
|
|
maybeEncryptMock: 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: maybeEncryptMock,
|
|
}));
|
|
|
|
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 {},
|
|
}));
|
|
|
|
const audit = { writeAudit: vi.fn() };
|
|
const access = { getAlbumIdFromLink: vi.fn() };
|
|
const journeyFx = makeStorageFixture('journey/');
|
|
let svc: ImmichService;
|
|
|
|
const USER = 1;
|
|
|
|
async function seedUser(
|
|
id: number,
|
|
url: string | null,
|
|
key: string | null,
|
|
autoUpload = 0,
|
|
allowInsecureTls = 0,
|
|
): Promise<void> {
|
|
await upsertRow(await sharedTestOrm(testDb), Users, {
|
|
id,
|
|
username: `u${id}`,
|
|
email: `u${id}@example.test`,
|
|
password_hash: 'x',
|
|
immich_url: url,
|
|
immich_api_key: key,
|
|
immich_auto_upload: autoUpload,
|
|
immich_allow_insecure_tls: allowInsecureTls,
|
|
});
|
|
}
|
|
|
|
/** The user's stored Immich URL and TLS switch, as the settings page round-trips them. */
|
|
async function storedConnection(
|
|
id: number,
|
|
): Promise<{ immich_url: string | null | undefined; immich_allow_insecure_tls: number }> {
|
|
const row = await readUser(await sharedTestOrm(testDb), id);
|
|
return { immich_url: row.immich_url, immich_allow_insecure_tls: row.immich_allow_insecure_tls };
|
|
}
|
|
|
|
/** 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(async () => {
|
|
const immichRepo = await createTestUserImmichRepo(testDb);
|
|
svc = new ImmichService(
|
|
audit as unknown as AuditService,
|
|
access as unknown as MemoriesAccessService,
|
|
journeyFx.storage,
|
|
immichRepo,
|
|
await createTestUnitOfWork(testDb),
|
|
);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
decryptMock.mockImplementation((v: string) => v);
|
|
maybeEncryptMock.mockImplementation((v: string) => v);
|
|
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
|
|
await deleteRows(await sharedTestOrm(testDb), Users);
|
|
await seedUser(USER, 'https://immich.test', 'key-1');
|
|
});
|
|
|
|
afterAll(() => testDb.close());
|
|
|
|
describe('getImmichCredentials', () => {
|
|
it('IMMICH-001: returns null when the user row is missing', async () => {
|
|
expect(await svc.getImmichCredentials(999)).toBeNull();
|
|
});
|
|
|
|
it('IMMICH-002: returns null without a URL', async () => {
|
|
await seedUser(2, null, 'key');
|
|
expect(await svc.getImmichCredentials(2)).toBeNull();
|
|
});
|
|
|
|
it('IMMICH-003: returns null without an API key', async () => {
|
|
await seedUser(3, 'https://immich.test', null);
|
|
expect(await svc.getImmichCredentials(3)).toBeNull();
|
|
});
|
|
|
|
it('IMMICH-004: returns null when the stored key cannot be decrypted', async () => {
|
|
decryptMock.mockReturnValue(null);
|
|
expect(await svc.getImmichCredentials(USER)).toBeNull();
|
|
});
|
|
|
|
it('IMMICH-005: returns the decrypted pair otherwise', async () => {
|
|
expect(await svc.getImmichCredentials(USER)).toEqual({
|
|
immich_url: 'https://immich.test',
|
|
immich_api_key: 'key-1',
|
|
allow_insecure_tls: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
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', async () => {
|
|
expect(await svc.getConnectionSettings(USER)).toEqual({
|
|
immich_url: 'https://immich.test',
|
|
connected: true,
|
|
auto_upload: false,
|
|
allow_insecure_tls: false,
|
|
});
|
|
});
|
|
|
|
it('IMMICH-008: reports an empty URL and not connected when it is not', async () => {
|
|
await seedUser(4, null, null);
|
|
expect(await svc.getConnectionSettings(4)).toEqual({
|
|
immich_url: '',
|
|
connected: false,
|
|
auto_upload: false,
|
|
allow_insecure_tls: false,
|
|
});
|
|
});
|
|
|
|
it('IMMICH-009: surfaces the auto-upload flag both ways', async () => {
|
|
await svc.setImmichAutoUpload(USER, true);
|
|
expect((await svc.getConnectionSettings(USER)).auto_upload).toBe(true);
|
|
await svc.setImmichAutoUpload(USER, false);
|
|
expect((await 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((await 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(await svc.getImmichCredentials(USER)).toEqual({
|
|
immich_url: 'https://new.test',
|
|
immich_api_key: 'k2',
|
|
allow_insecure_tls: false,
|
|
});
|
|
});
|
|
|
|
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(await svc.getImmichCredentials(USER)).toBeNull();
|
|
});
|
|
|
|
it('IMMICH-052: writes auto_upload with the connection, and keeps it when the flag is absent', async () => {
|
|
const autoUpload = async () => (await readUser(await sharedTestOrm(testDb), USER)).immich_auto_upload;
|
|
|
|
await svc.saveImmichSettings(USER, 'https://new.test', 'k2', null, undefined, true);
|
|
expect(await autoUpload()).toBe(1);
|
|
await svc.saveImmichSettings(USER, 'https://new.test', 'k2', null);
|
|
expect(await autoUpload()).toBe(1);
|
|
await svc.saveImmichSettings(USER, undefined, undefined, null, undefined, false);
|
|
expect(await autoUpload()).toBe(0);
|
|
});
|
|
|
|
it('IMMICH-053: a refused URL writes no auto_upload either', async () => {
|
|
checkSsrf.mockResolvedValue({ allowed: false, error: 'blocked host' });
|
|
|
|
await svc.saveImmichSettings(USER, 'http://169.254.169.254', 'k', null, undefined, true);
|
|
|
|
expect((await readUser(await sharedTestOrm(testDb), USER)).immich_auto_upload).toBe(0);
|
|
});
|
|
|
|
it('IMMICH-054: a failing auto_upload write rolls the new connection back with it', async () => {
|
|
const spy = vi.spyOn(svc, 'setImmichAutoUpload').mockRejectedValueOnce(new Error('boom'));
|
|
try {
|
|
await expect(svc.saveImmichSettings(USER, 'https://new.test', 'k2', null, undefined, true)).rejects.toThrow(
|
|
'boom',
|
|
);
|
|
expect((await svc.getImmichCredentials(USER))!.immich_url).toBe('https://immich.test');
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
// R6 — the encrypt call still runs; this file mocks `maybe_encrypt_api_key`
|
|
// as identity everywhere else (so cases above can assert on the plaintext
|
|
// round-trip), so this one case swaps in the REAL crypto for the duration
|
|
// of a single call and reads the raw column back — proving
|
|
// `UsersRepository.setImmichSettings` never bypasses the service's encrypt
|
|
// step. `beforeEach` restores the identity stub for every other case.
|
|
it('IMMICH-051 (R6): the stored immich_api_key is the encrypted envelope, never the plaintext', async () => {
|
|
const real = await vi.importActual<typeof import('../../../src/nest/common/crypto/apiKeyCrypto')>(
|
|
'../../../src/nest/common/crypto/apiKeyCrypto',
|
|
);
|
|
maybeEncryptMock.mockImplementation(real.maybe_encrypt_api_key);
|
|
|
|
const plaintext = 'synthetic-test-immich-key-001';
|
|
await svc.saveImmichSettings(USER, 'https://immich.test', plaintext, null);
|
|
|
|
const row = await readUser(await sharedTestOrm(testDb), USER);
|
|
expect(row.immich_api_key).not.toBe(plaintext);
|
|
expect(row.immich_api_key?.startsWith('enc:v1:')).toBe(true);
|
|
});
|
|
});
|
|
|
|
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 () => {
|
|
await 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 () => {
|
|
await 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 });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Three raw pages of a Sydney library, read newest first: the whole of 16 March
|
|
* comes back before 15 March starts, which is what makes an answered page cost
|
|
* more than one raw page once the day filter runs (#2336).
|
|
*/
|
|
const NEXT_DAY = [
|
|
{ id: 'next-1', fileCreatedAt: '2026-03-16T01:00:00.000Z', localDateTime: '2026-03-16T12:00:00.000Z' },
|
|
{ id: 'next-2', fileCreatedAt: '2026-03-16T00:30:00.000Z', localDateTime: '2026-03-16T11:30:00.000Z' },
|
|
];
|
|
const DAY_HEAD = [
|
|
{ id: 'day-1', fileCreatedAt: '2026-03-15T09:00:00.000Z', localDateTime: '2026-03-15T20:00:00.000Z' },
|
|
{ id: 'day-2', fileCreatedAt: '2026-03-15T08:00:00.000Z', localDateTime: '2026-03-15T19:00:00.000Z' },
|
|
];
|
|
const DAY_TAIL = [
|
|
{ id: 'day-3', fileCreatedAt: '2026-03-15T07:00:00.000Z', localDateTime: '2026-03-15T18:00:00.000Z' },
|
|
{ id: 'day-4', fileCreatedAt: '2026-03-15T06:00:00.000Z', localDateTime: '2026-03-15T17:00:00.000Z' },
|
|
];
|
|
|
|
describe('searchPhotos', () => {
|
|
it('IMMICH-027: 400s without credentials and 502s when the server is unreachable', async () => {
|
|
await 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' } },
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
// By id, not by position: the result is ordered newest first, so 'b' leads.
|
|
const assets = (await svc.searchPhotos(USER)).assets!;
|
|
const a = assets.find((x) => x.id === 'a');
|
|
const b = assets.find((x) => x.id === 'b');
|
|
|
|
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: pads the upstream date bounds by a day on each side', async () => {
|
|
// The bounds filter on fileCreatedAt, a UTC instant, while from/to name
|
|
// calendar days on a wall clock. No zone sits further than 14 hours from
|
|
// UTC, so a day of slack each way reaches every asset that belongs to them;
|
|
// the local-date filter below narrows the page again (#2336).
|
|
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('2025-12-31T00:00:00.000Z');
|
|
expect(body.takenBefore).toBe('2026-02-01T23:59:59.999Z');
|
|
// Load-bearing on Immich >= 1.133: hidden assets must not cross the wire.
|
|
expect(body.visibility).toBe('timeline');
|
|
expect(body.order).toBe('desc');
|
|
});
|
|
|
|
it('IMMICH-032c: pads only the bound it was given', async () => {
|
|
safeFetch.mockResolvedValue(upstream({ json: { assets: { items: [] } } }));
|
|
|
|
await svc.searchPhotos(USER, '2026-01-01');
|
|
const fromOnly = JSON.parse((safeFetch.mock.calls[0][1] as { body: string }).body);
|
|
expect(fromOnly.takenAfter).toBe('2025-12-31T00:00:00.000Z');
|
|
expect(fromOnly.takenBefore).toBeUndefined();
|
|
|
|
await svc.searchPhotos(USER, undefined, '2026-01-31');
|
|
const toOnly = JSON.parse((safeFetch.mock.calls[1][1] as { body: string }).body);
|
|
expect(toOnly.takenAfter).toBeUndefined();
|
|
expect(toOnly.takenBefore).toBe('2026-02-01T23:59:59.999Z');
|
|
});
|
|
|
|
it('IMMICH-032d: keeps the photos whose OWN local capture date is the day asked for', async () => {
|
|
// A Sydney reader asking for the 15th: the 07:32 shot is 20:32Z on the 14th
|
|
// and the UTC window used to miss it, while the next morning leaked in.
|
|
safeFetch.mockResolvedValue(
|
|
upstream({
|
|
json: {
|
|
assets: {
|
|
items: [
|
|
{ id: 'morning', fileCreatedAt: '2026-03-14T20:32:00.000Z', localDateTime: '2026-03-15T07:32:00.000Z' },
|
|
{ id: 'evening', fileCreatedAt: '2026-03-15T09:00:00.000Z', localDateTime: '2026-03-15T20:00:00.000Z' },
|
|
{
|
|
id: 'next-morning',
|
|
fileCreatedAt: '2026-03-15T21:00:00.000Z',
|
|
localDateTime: '2026-03-16T08:00:00.000Z',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15');
|
|
|
|
expect(result.assets!.map((a) => a.id)).toEqual(['evening', 'morning']);
|
|
expect(result.assets![1]).toMatchObject({
|
|
takenAt: '2026-03-14T20:32:00.000Z',
|
|
localTakenAt: '2026-03-15T07:32:00.000Z',
|
|
});
|
|
});
|
|
|
|
it('IMMICH-049: a server without localDateTime answers exactly as it did before', async () => {
|
|
// An old or forked Immich: the fallback is the capture instant, so the day
|
|
// is the UTC day the window has always searched, and the padding buys the
|
|
// neighbouring days nothing.
|
|
safeFetch.mockResolvedValue(
|
|
upstream({
|
|
json: {
|
|
assets: {
|
|
items: [
|
|
{ id: 'day-before', fileCreatedAt: '2026-03-14T23:00:00.000Z' },
|
|
{ id: 'in-day', fileCreatedAt: '2026-03-15T09:00:00.000Z' },
|
|
{ id: 'day-after', fileCreatedAt: '2026-03-16T01:00:00.000Z' },
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15');
|
|
|
|
expect(result.assets!.map((a) => a.id)).toEqual(['in-day']);
|
|
expect(result.assets![0].localTakenAt).toBeNull();
|
|
});
|
|
|
|
it('IMMICH-050: fills the answered page from as many raw pages as the day filter costs', async () => {
|
|
// `order: 'desc'` hands the padding day back first, so the first raw page of
|
|
// a day whose neighbour was busy is entirely the wrong day. Answering that
|
|
// page empty made the picker load nothing several times over, and made the
|
|
// MCP tool report a day that has photos as having none (#2336).
|
|
safeFetch
|
|
.mockResolvedValueOnce(upstream({ json: { assets: { items: NEXT_DAY } } }))
|
|
.mockResolvedValueOnce(upstream({ json: { assets: { items: DAY_HEAD } } }));
|
|
|
|
const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 1, 2);
|
|
|
|
expect(result.assets!.map((a) => a.id)).toEqual(['day-1', 'day-2']);
|
|
expect(result.hasMore).toBe(true);
|
|
expect(safeFetch).toHaveBeenCalledTimes(2);
|
|
expect(JSON.parse((safeFetch.mock.calls[1][1] as { body: string }).body).page).toBe(2);
|
|
});
|
|
|
|
it('IMMICH-050b: pages on without repeating an asset the page before already answered with', async () => {
|
|
// The scan restarts at raw page 1 every call, so what it skips has to be
|
|
// counted in assets that survived the filter rather than in raw rows.
|
|
const page = (items: unknown[]) => upstream({ json: { assets: { items } } });
|
|
safeFetch
|
|
.mockResolvedValueOnce(page(NEXT_DAY))
|
|
.mockResolvedValueOnce(page(DAY_HEAD))
|
|
.mockResolvedValueOnce(page(NEXT_DAY))
|
|
.mockResolvedValueOnce(page(DAY_HEAD))
|
|
.mockResolvedValueOnce(page(DAY_TAIL));
|
|
|
|
const first = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 1, 2);
|
|
const second = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 2, 2);
|
|
|
|
expect(first.assets!.map((a) => a.id)).toEqual(['day-1', 'day-2']);
|
|
expect(second.assets!.map((a) => a.id)).toEqual(['day-3', 'day-4']);
|
|
});
|
|
|
|
it('IMMICH-050c: gives up after the page budget instead of scanning a library forever', async () => {
|
|
// Every raw page is the padding day, so the answered page can never fill.
|
|
// hasMore has to go false here: the next call would scan the same pages and
|
|
// hand back the same empty page for ever.
|
|
safeFetch.mockResolvedValue(upstream({ json: { assets: { items: NEXT_DAY } } }));
|
|
|
|
const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 1, 2);
|
|
|
|
expect(result.assets).toEqual([]);
|
|
expect(result.hasMore).toBe(false);
|
|
// SEARCH_MAX_RAW_PAGES.
|
|
expect(safeFetch).toHaveBeenCalledTimes(20);
|
|
});
|
|
|
|
it('IMMICH-050e: a page past the twentieth still answers on a window full of the searched day', async () => {
|
|
// The budget used to be 20 raw pages whatever page was asked for, and every
|
|
// call restarts at raw page 1, so answered page 21 onwards was always empty
|
|
// with hasMore false: a trip stopped at 1,000 photos without a word (#1587).
|
|
safeFetch.mockImplementation(async (_url: string, init: { body: string }) => {
|
|
const rawPage = JSON.parse(init.body).page as number;
|
|
return upstream({
|
|
json: {
|
|
assets: {
|
|
items: [
|
|
{
|
|
id: `d-${rawPage}-1`,
|
|
fileCreatedAt: '2026-03-15T09:00:00.000Z',
|
|
localDateTime: '2026-03-15T20:00:00.000Z',
|
|
},
|
|
{
|
|
id: `d-${rawPage}-2`,
|
|
fileCreatedAt: '2026-03-15T08:00:00.000Z',
|
|
localDateTime: '2026-03-15T19:00:00.000Z',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 25, 2);
|
|
|
|
expect(result.assets!.map((a) => a.id)).toEqual(['d-25-1', 'd-25-2']);
|
|
expect(result.hasMore).toBe(true);
|
|
expect(safeFetch).toHaveBeenCalledTimes(25);
|
|
});
|
|
|
|
it('IMMICH-050f: a deep page still stops once its own allowance of padding pages is spent', async () => {
|
|
// Page 3 needs the two pages in front of it plus the same allowance page 1
|
|
// gets, and a window of nothing but the neighbouring day never fills it.
|
|
safeFetch.mockResolvedValue(upstream({ json: { assets: { items: NEXT_DAY } } }));
|
|
|
|
const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 3, 2);
|
|
|
|
expect(result.assets).toEqual([]);
|
|
expect(result.hasMore).toBe(false);
|
|
expect(safeFetch).toHaveBeenCalledTimes(22);
|
|
});
|
|
|
|
it('IMMICH-050g: a huge page stops at the absolute ceiling instead of walking the whole library', async () => {
|
|
// page is not bounded by the routes, and the relative allowance alone would
|
|
// have read a million raw pages here before answering.
|
|
safeFetch.mockImplementation(async (_url: string, init: { body: string }) => {
|
|
const rawPage = JSON.parse(init.body).page as number;
|
|
return upstream({
|
|
json: {
|
|
assets: {
|
|
items: [
|
|
{
|
|
id: `h-${rawPage}-1`,
|
|
fileCreatedAt: '2026-03-15T09:00:00.000Z',
|
|
localDateTime: '2026-03-15T20:00:00.000Z',
|
|
},
|
|
{
|
|
id: `h-${rawPage}-2`,
|
|
fileCreatedAt: '2026-03-15T08:00:00.000Z',
|
|
localDateTime: '2026-03-15T19:00:00.000Z',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 1_000_000, 2);
|
|
|
|
expect(result.assets).toEqual([]);
|
|
expect(result.hasMore).toBe(false);
|
|
// The ceiling: the pages in front of the picker's deepest page plus its
|
|
// allowance, 250 + 20.
|
|
expect(safeFetch).toHaveBeenCalledTimes(270);
|
|
});
|
|
|
|
it('IMMICH-050h: the deepest page the picker asks for still gets its full allowance under the ceiling', async () => {
|
|
// The picker never asks a date-bounded search past page 251, by Select all
|
|
// or by scrolling: 250 pages in front of it plus the allowance of 20.
|
|
safeFetch.mockResolvedValue(upstream({ json: { assets: { items: NEXT_DAY } } }));
|
|
|
|
const deepest = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', PROVIDER_SELECT_ALL_MAX_PAGES + 1, 2);
|
|
|
|
expect(deepest.hasMore).toBe(false);
|
|
expect(safeFetch).toHaveBeenCalledTimes(270);
|
|
|
|
// One page further the ceiling already bites: the allowance shrinks by one.
|
|
safeFetch.mockClear();
|
|
await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', PROVIDER_SELECT_ALL_MAX_PAGES + 2, 2);
|
|
expect(safeFetch).toHaveBeenCalledTimes(270);
|
|
});
|
|
|
|
it('IMMICH-050i: at the picker page size of 200 the deepest page it asks for answers in full', async () => {
|
|
// A trip of more than 50,000 photos, every raw page full of the searched
|
|
// day: page 251 is photos 50,001 to 50,200, read off raw page 251.
|
|
safeFetch.mockImplementation(async (_url: string, init: { body: string }) => {
|
|
const { page, size } = JSON.parse(init.body) as { page: number; size: number };
|
|
return upstream({
|
|
json: {
|
|
assets: {
|
|
items: Array.from({ length: size }, (_, i) => ({
|
|
id: `p-${page}-${i}`,
|
|
fileCreatedAt: '2026-03-15T09:00:00.000Z',
|
|
localDateTime: '2026-03-15T20:00:00.000Z',
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', PROVIDER_SELECT_ALL_MAX_PAGES + 1, 200);
|
|
|
|
expect(result.assets).toHaveLength(200);
|
|
expect(result.assets!.every((a) => a.id.startsWith('p-251-'))).toBe(true);
|
|
expect(result.hasMore).toBe(true);
|
|
expect(safeFetch).toHaveBeenCalledTimes(251);
|
|
});
|
|
|
|
it('IMMICH-050d: an unfiltered browse still costs one round trip, at the page it was asked for', async () => {
|
|
// Nothing narrows those pages, so a raw page and an answered page are the
|
|
// same page and the scan must not start over at page 1.
|
|
safeFetch.mockResolvedValue(
|
|
upstream({
|
|
json: {
|
|
assets: {
|
|
items: [
|
|
{ id: 'x', visibility: 'hidden' },
|
|
{ id: 'y', visibility: 'hidden' },
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await svc.searchPhotos(USER, undefined, undefined, 3, 2);
|
|
|
|
expect(safeFetch).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse((safeFetch.mock.calls[0][1] as { body: string }).body).page).toBe(3);
|
|
// Every row was hidden, and pagination still advances past that page.
|
|
expect(result.assets).toEqual([]);
|
|
expect(result.hasMore).toBe(true);
|
|
});
|
|
|
|
it('IMMICH-032b: orders each page itself, so an unsorted page still lands chronological within itself', async () => {
|
|
// Older builds drop the unknown property silently (whitelist: true without
|
|
// forbidNonWhitelisted), which is exactly why the request cannot be trusted.
|
|
safeFetch.mockResolvedValue(
|
|
upstream({
|
|
json: {
|
|
assets: {
|
|
items: [
|
|
{ id: 'older', fileCreatedAt: '2026-03-01T09:00:00Z' },
|
|
{ id: 'newest', fileCreatedAt: '2026-03-31T09:00:00Z' },
|
|
{ id: 'middle', fileCreatedAt: '2026-03-15T09:00:00Z' },
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await svc.searchPhotos(USER);
|
|
|
|
expect(result.assets!.map((a) => a.id)).toEqual(['newest', 'middle', 'older']);
|
|
});
|
|
});
|
|
|
|
describe('getAssetInfo', () => {
|
|
it('IMMICH-033: 404s when the owner has no credentials', async () => {
|
|
await 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 () => {
|
|
await 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 });
|
|
});
|
|
|
|
it('IMMICH-037b: reports the media type here too, not only in the listing', async () => {
|
|
// Same mapping as the listing, so the detail route and the picker agree on
|
|
// what an asset is. Nothing persists it from here yet — trek_photos.media_type
|
|
// comes from the add call's media_types.
|
|
safeFetch.mockResolvedValueOnce(upstream({ json: { id: 'clip', type: 'VIDEO' } }));
|
|
expect((await svc.getAssetInfo(USER, 'clip')).data.mediaType).toBe('video');
|
|
|
|
safeFetch.mockResolvedValueOnce(upstream({ json: { id: 'still', type: 'IMAGE' } }));
|
|
expect((await svc.getAssetInfo(USER, 'still')).data.mediaType).toBe('image');
|
|
});
|
|
});
|
|
|
|
describe('getAlbumPhotos', () => {
|
|
it('IMMICH-037c: 400s without credentials', async () => {
|
|
await seedUser(11, null, null);
|
|
expect(await svc.getAlbumPhotos(11, 'alb-1')).toEqual({ error: 'Immich not configured', status: 400 });
|
|
});
|
|
|
|
it('IMMICH-037d: orders an album newest first and keeps its coordinates and media type', async () => {
|
|
// The v2 branch reads /api/albums/{id} directly and never passes a search,
|
|
// so this ordering is the only one an album on that version ever gets.
|
|
safeFetch.mockResolvedValue(
|
|
upstream({
|
|
json: {
|
|
assets: [
|
|
{
|
|
id: 'older',
|
|
fileCreatedAt: '2026-03-01T09:00:00Z',
|
|
localDateTime: '2026-03-01T18:00:00.000Z',
|
|
type: 'IMAGE',
|
|
exifInfo: { latitude: 35.0, longitude: 135.7 },
|
|
},
|
|
{ id: 'clip', fileCreatedAt: '2026-03-31T09:00:00Z', type: 'VIDEO' },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const assets = (await svc.getAlbumPhotos(USER, 'alb-1')).assets!;
|
|
|
|
expect(assets.map((a) => a.id)).toEqual(['clip', 'older']);
|
|
expect(assets[0].mediaType).toBe('video');
|
|
expect(assets[1]).toMatchObject({ lat: 35.0, lng: 135.7 });
|
|
// The album path carries the same local capture stamp the search path does,
|
|
// so a photo picked out of an album lands under the day heading a search
|
|
// would have given it instead of under the UTC one (#2336). A server that
|
|
// predates the field leaves it null, and the readers fall back to takenAt.
|
|
expect(assets[1].localTakenAt).toBe('2026-03-01T18:00:00.000Z');
|
|
expect(assets[0].localTakenAt).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('fetchImmichThumbnailBytes', () => {
|
|
it('IMMICH-038: 404s without credentials for the owner', async () => {
|
|
await 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('streamImmichAsset', () => {
|
|
function makeRes() {
|
|
return { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn(), end: vi.fn() };
|
|
}
|
|
|
|
it('IMMICH-041b: answers 404 when the owner has no connection, instead of leaving the request open', async () => {
|
|
// It used to return { error, status } and touch nothing. All four callers
|
|
// ignored that return, so the client got no status line at all and waited
|
|
// out its own timeout. Synology's counterpart always wrote; this matches.
|
|
await seedUser(11, null, null);
|
|
const res = makeRes();
|
|
|
|
await svc.streamImmichAsset(res as never, USER, 'a1', 'thumbnail', 11);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(404);
|
|
expect(res.json).toHaveBeenCalledWith({ error: 'Not found' });
|
|
expect(safeFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('IMMICH-041c: reads the connection of the OWNER, not of the viewer', async () => {
|
|
// A shared album is proxied with the owner's key on the viewer's behalf, so
|
|
// a viewer who happens to have Immich configured must not paper over an
|
|
// owner who does not.
|
|
await seedUser(USER, 'https://immich.example', 'viewer-key');
|
|
await seedUser(12, null, null);
|
|
const res = makeRes();
|
|
|
|
await svc.streamImmichAsset(res as never, USER, 'a1', 'original', 12);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(404);
|
|
expect(safeFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('IMMICH-041d: proxies with the owner key once a connection exists', async () => {
|
|
await seedUser(13, 'https://immich.example', 'owner-key');
|
|
safeFetch.mockResolvedValue(upstream({ contentType: 'image/jpeg', body: 'bytes' }));
|
|
const res = makeRes();
|
|
|
|
await svc.streamImmichAsset(res as never, USER, 'a1', 'thumbnail', 13);
|
|
|
|
expect(safeFetch).toHaveBeenCalledTimes(1);
|
|
const [url, init] = safeFetch.mock.calls[0];
|
|
expect(url).toContain('/api/assets/a1/thumbnail');
|
|
expect((init.headers as Record<string, string>)['x-api-key']).toBe('owner-key');
|
|
});
|
|
});
|
|
|
|
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' });
|
|
await 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 });
|
|
});
|
|
});
|
|
|
|
describe('uploadToImmich', () => {
|
|
function writeJourneyObject(name: string, bytes: string): void {
|
|
const dir = path.join(journeyFx.root, 'journey');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, name), bytes);
|
|
}
|
|
|
|
it('IMMICH-UP-001: returns null without credentials and never fetches', async () => {
|
|
expect(await svc.uploadToImmich(999, 'journey/pic.jpg', 'pic.jpg')).toBeNull();
|
|
expect(safeFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('IMMICH-UP-002: returns null for a path outside the journey category', async () => {
|
|
await seedUser(USER, 'https://immich.test', 'key-1');
|
|
expect(await svc.uploadToImmich(USER, 'files/doc.pdf', 'doc.pdf')).toBeNull();
|
|
expect(safeFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('IMMICH-UP-003: returns null when the object is missing (old existsSync guard)', async () => {
|
|
await seedUser(USER, 'https://immich.test', 'key-1');
|
|
expect(await svc.uploadToImmich(USER, 'journey/gone.jpg', 'gone.jpg')).toBeNull();
|
|
expect(safeFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('IMMICH-UP-004: posts the bytes as multipart and answers the created asset id', async () => {
|
|
await seedUser(USER, 'https://immich.test', 'key-1');
|
|
writeJourneyObject('up.jpg', 'jpeg-bytes-here');
|
|
safeFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'immich-42' }) });
|
|
|
|
expect(await svc.uploadToImmich(USER, 'journey/up.jpg', 'up.jpg')).toBe('immich-42');
|
|
|
|
const [url, init] = safeFetch.mock.calls[0] as [
|
|
string,
|
|
{ method: string; body: Buffer; headers: Record<string, string> },
|
|
];
|
|
expect(url).toBe('https://immich.test/api/assets');
|
|
expect(init.method).toBe('POST');
|
|
expect(init.headers['x-api-key']).toBe('key-1');
|
|
expect(init.body.includes(Buffer.from('jpeg-bytes-here'))).toBe(true);
|
|
});
|
|
|
|
it('IMMICH-UP-005: a rejected upstream answers null, not a throw', async () => {
|
|
await seedUser(USER, 'https://immich.test', 'key-1');
|
|
writeJourneyObject('rej.jpg', 'bytes');
|
|
safeFetch.mockResolvedValueOnce({ ok: false, status: 500 });
|
|
expect(await svc.uploadToImmich(USER, 'journey/rej.jpg', 'rej.jpg')).toBeNull();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The self-signed switch (#2475). Every request to a user's Immich carries the
|
|
* TLS options as safeFetch's third argument; these pin that no path was left
|
|
* on the strict default and that the switch is read fail closed.
|
|
*/
|
|
describe('self-signed certificates', () => {
|
|
const LAX = { rejectUnauthorized: false };
|
|
const STRICT = { rejectUnauthorized: true };
|
|
|
|
function makeRes() {
|
|
return { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn(), end: vi.fn(), headersSent: false };
|
|
}
|
|
|
|
function writeJourneyObject(name: string, bytes: string): void {
|
|
const dir = path.join(journeyFx.root, 'journey');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, name), bytes);
|
|
}
|
|
|
|
/** Drives every request the service makes against the user's own server. */
|
|
async function exerciseEveryPath(userId: number): Promise<void> {
|
|
access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' });
|
|
safeFetch.mockImplementation(async (url: string) => {
|
|
if (url.endsWith('/api/albums/album-1')) return upstream({ json: {} });
|
|
if (url.includes('/api/search/metadata')) return upstream({ json: { assets: { items: [] } } });
|
|
if (url.includes('/api/albums')) return upstream({ json: [] });
|
|
if (url.endsWith('/api/assets')) return upstream({ json: { id: 'up-1' } });
|
|
return upstream({ json: {} });
|
|
});
|
|
writeJourneyObject('tls.jpg', 'bytes');
|
|
|
|
await svc.getConnectionStatus(userId);
|
|
await svc.browseTimeline(userId);
|
|
await svc.searchPhotos(userId);
|
|
await svc.getAssetInfo(userId, 'a1');
|
|
await svc.fetchImmichThumbnailBytes(userId, 'a1');
|
|
await svc.streamImmichAsset(makeRes() as never, userId, 'a1', 'thumbnail');
|
|
await svc.listAlbums(userId);
|
|
await svc.getAlbumPhotos(userId, 'album-1');
|
|
await svc.collectAlbumSelection('1', 'l1', userId);
|
|
await svc.uploadToImmich(userId, 'journey/tls.jpg', 'tls.jpg');
|
|
}
|
|
|
|
/**
|
|
* status, timeline, search, info, thumbnail, stream, both album lists, then
|
|
* the album detail and its v3 search twice (browse and sync), and the upload.
|
|
*/
|
|
const EVERY_PATH_CALLS = 13;
|
|
|
|
it('IMMICH-TLS-001: only a stored 1 turns the switch on', async () => {
|
|
await seedUser(USER, 'https://immich.test', 'key-1', 0, 1);
|
|
expect((await svc.getImmichCredentials(USER))!.allow_insecure_tls).toBe(true);
|
|
|
|
await seedUser(USER, 'https://immich.test', 'key-1', 0, 0);
|
|
expect((await svc.getImmichCredentials(USER))!.allow_insecure_tls).toBe(false);
|
|
|
|
// Anything the column should never hold reads as off, not as truthy.
|
|
await seedUser(USER, 'https://immich.test', 'key-1', 0, 2);
|
|
expect((await svc.getImmichCredentials(USER))!.allow_insecure_tls).toBe(false);
|
|
});
|
|
|
|
it('IMMICH-TLS-002: with the switch on, every request to the server skips the certificate check', async () => {
|
|
await seedUser(USER, 'https://immich.test', 'key-1', 0, 1);
|
|
|
|
await exerciseEveryPath(USER);
|
|
|
|
expect(safeFetch).toHaveBeenCalledTimes(EVERY_PATH_CALLS);
|
|
for (const call of safeFetch.mock.calls) expect(call[2]).toMatchObject(LAX);
|
|
});
|
|
|
|
it('IMMICH-TLS-003: with the switch off, every request keeps the certificate check', async () => {
|
|
await exerciseEveryPath(USER);
|
|
|
|
expect(safeFetch).toHaveBeenCalledTimes(EVERY_PATH_CALLS);
|
|
for (const call of safeFetch.mock.calls) expect(call[2]).toMatchObject(STRICT);
|
|
});
|
|
|
|
it('IMMICH-TLS-004: a shared photo follows the switch of its owner, not of the viewer', async () => {
|
|
// The request goes to the owner's server with the owner's key, so the
|
|
// owner decided whether that server's certificate is trusted.
|
|
await seedUser(USER, 'https://viewer.test', 'viewer-key', 0, 0);
|
|
await seedUser(20, 'https://owner.test', 'owner-key', 0, 1);
|
|
safeFetch.mockResolvedValue(upstream({ json: { id: 'a1' } }));
|
|
|
|
await svc.getAssetInfo(USER, 'a1', 20);
|
|
await svc.fetchImmichThumbnailBytes(USER, 'a1', 20);
|
|
await svc.streamImmichAsset(makeRes() as never, USER, 'a1', 'original', 20);
|
|
|
|
expect(safeFetch).toHaveBeenCalledTimes(3);
|
|
for (const call of safeFetch.mock.calls) {
|
|
expect(call[0]).toContain('https://owner.test');
|
|
expect(call[2]).toMatchObject(LAX);
|
|
}
|
|
|
|
safeFetch.mockClear();
|
|
await seedUser(USER, 'https://viewer.test', 'viewer-key', 0, 1);
|
|
await seedUser(20, 'https://owner.test', 'owner-key', 0, 0);
|
|
await svc.getAssetInfo(USER, 'a1', 20);
|
|
expect(safeFetch.mock.calls[0][2]).toMatchObject(STRICT);
|
|
});
|
|
|
|
it('IMMICH-TLS-005: saving without the switch keeps it, saving with it sets it, disconnecting clears it', async () => {
|
|
await svc.saveImmichSettings(USER, 'https://immich.test', 'key-1', null, true);
|
|
expect((await svc.getConnectionSettings(USER)).allow_insecure_tls).toBe(true);
|
|
|
|
// An older client does not send the field; its save must not turn the switch off.
|
|
await svc.saveImmichSettings(USER, 'https://immich.test', 'key-1', null);
|
|
expect((await svc.getConnectionSettings(USER)).allow_insecure_tls).toBe(true);
|
|
|
|
await svc.saveImmichSettings(USER, 'https://immich.test', 'key-1', null, false);
|
|
expect((await svc.getConnectionSettings(USER)).allow_insecure_tls).toBe(false);
|
|
|
|
await svc.saveImmichSettings(USER, 'https://immich.test', 'key-1', null, true);
|
|
await svc.saveImmichSettings(USER, undefined, undefined, null, true);
|
|
expect(await storedConnection(USER)).toEqual({ immich_url: null, immich_allow_insecure_tls: 0 });
|
|
});
|
|
|
|
it('IMMICH-TLS-010: the switch trusts one server, so a new URL saved without it starts off', async () => {
|
|
await seedUser(USER, 'https://nas.local', 'key-1', 0, 1);
|
|
|
|
// Same server with a trailing slash: still the same connection, the switch stays.
|
|
await svc.saveImmichSettings(USER, 'https://nas.local/', 'key-1', null);
|
|
expect((await svc.getConnectionSettings(USER)).allow_insecure_tls).toBe(true);
|
|
|
|
await svc.saveImmichSettings(USER, 'https://photos.example.com', 'key-2', null);
|
|
expect(await storedConnection(USER)).toEqual({
|
|
immich_url: 'https://photos.example.com',
|
|
immich_allow_insecure_tls: 0,
|
|
});
|
|
|
|
// Sent along with the new URL, it holds for that server.
|
|
await svc.saveImmichSettings(USER, 'https://other.example.com', 'key-3', null, true);
|
|
expect((await svc.getConnectionSettings(USER)).allow_insecure_tls).toBe(true);
|
|
});
|
|
|
|
it('IMMICH-TLS-006: a URL the guard refuses leaves the stored switch alone', async () => {
|
|
await seedUser(USER, 'https://immich.test', 'key-1', 0, 1);
|
|
checkSsrf.mockResolvedValue({ allowed: false, error: 'blocked host' });
|
|
|
|
await svc.saveImmichSettings(USER, 'http://169.254.169.254', 'k', null, false);
|
|
|
|
expect((await svc.getConnectionSettings(USER)).allow_insecure_tls).toBe(true);
|
|
});
|
|
|
|
it('IMMICH-TLS-007: the connection test uses the switch it is handed, strict by default', async () => {
|
|
safeFetch.mockResolvedValue(upstream({ json: { name: 'Ada' } }));
|
|
|
|
await svc.testConnection('https://immich.test', 'k', true);
|
|
await svc.testConnection('https://immich.test', 'k', false);
|
|
await svc.testConnection('https://immich.test', 'k');
|
|
|
|
expect(safeFetch.mock.calls.map((call) => call[2])).toEqual([LAX, STRICT, STRICT]);
|
|
});
|
|
|
|
it('IMMICH-TLS-008: a refused certificate names itself instead of a bare "fetch failed"', async () => {
|
|
// undici keeps the reason on `cause`; the settings card used to show only
|
|
// the outer message, which left the user nothing to go on.
|
|
const refused = () => new TypeError('fetch failed', { cause: new Error('self-signed certificate') });
|
|
safeFetch.mockRejectedValueOnce(refused());
|
|
expect(await svc.testConnection('https://immich.test', 'k')).toEqual({
|
|
connected: false,
|
|
error: 'fetch failed (self-signed certificate)',
|
|
});
|
|
|
|
safeFetch.mockRejectedValueOnce(refused());
|
|
expect(await svc.getConnectionStatus(USER)).toEqual({
|
|
connected: false,
|
|
error: 'fetch failed (self-signed certificate)',
|
|
});
|
|
});
|
|
|
|
it('IMMICH-TLS-009: the upload mirror gives up on a server that never answers', async () => {
|
|
writeJourneyObject('slow.jpg', 'bytes');
|
|
safeFetch.mockResolvedValueOnce(upstream({ json: { id: 'up-2' } }));
|
|
|
|
await svc.uploadToImmich(USER, 'journey/slow.jpg', 'slow.jpg');
|
|
|
|
expect((safeFetch.mock.calls[0][1] as { signal?: AbortSignal }).signal).toBeInstanceOf(AbortSignal);
|
|
});
|
|
});
|