mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
a910c2a837
Saved place price (#2471) - A price above zero that would be left without a currency is refused with a 400, over REST and through update_collection_place alike. A list has no base currency, so such an amount was read in each viewer's own default currency and went into a trip as the trip's. A price sent alone keeps the stored currency, and a zero price or clearing both fields still goes through. The check sits in the service because the MCP tool spreads the contract's shape, where a refine would never arrive. - An older row that holds an amount without a currency shows it as a bare number instead of in the viewer's own currency. - The Hungarian hint uses the informal register of its file, which is now prettier clean. Immich certificate switch (#2475) - The switch trusts one server. Saving a different URL without sending it turns it off; saving the same URL keeps it as before. The wiki says so. Moving day (#2476) - Google Maps and CoMaps are left out only when there is nothing to hand over. A day with a single stop opens it as a pin again, on the desktop and on the phone, as it did before. - Desktop: a moving day whose flight or train has no stations shows no route tools, since there is nothing left to draw. With stations they stay. - Phone: the drive between two stays on a day without stops or bookings shows once at the top instead of again at the bottom. - The export helpers take the carrier flags as one object instead of two booleans in a row. Import card (#2477) - The success and error marks use the theme tokens. - The e2e test names its upload with a made-up booking number. Tests: COLLECTIONS-SVC-132 and 133, COLLECTIONS-E2E-088, update_collection_place with a price and no currency, FE-COMP-COLEXTRAS-013 (011 adjusted), IMMICH-TLS-010, IMMICH-103 extended, FE-PLANNER-DAYPLAN-222 adjusted and 224, FE-MOB-PLTL-051, FE-MOB-PTLM-047, FE-W4BGT-023 and 024 check the mark colours.
906 lines
42 KiB
TypeScript
906 lines
42 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';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { makeStorageFixture } from '../../helpers/storage-fixture';
|
|
|
|
const audit = { writeAudit: vi.fn() };
|
|
const access = { getAlbumIdFromLink: vi.fn() };
|
|
const dbs = new DatabaseService(testDb);
|
|
const journeyFx = makeStorageFixture('journey/');
|
|
const svc = new ImmichService(dbs, audit as unknown as AuditService, access as unknown as MemoriesAccessService, journeyFx.storage);
|
|
|
|
const USER = 1;
|
|
|
|
function seedUser(id: number, url: string | null, key: string | null, autoUpload = 0, allowInsecureTls = 0): void {
|
|
testDb.prepare("INSERT OR REPLACE INTO users (id, username, email, password_hash, immich_url, immich_api_key, immich_auto_upload, immich_allow_insecure_tls) VALUES (?, ?, ?, 'x', ?, ?, ?, ?)")
|
|
.run(id, `u${id}`, `u${id}@example.test`, url, key, autoUpload, allowInsecureTls);
|
|
}
|
|
|
|
/** 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', 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', () => {
|
|
expect(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', () => {
|
|
seedUser(4, null, null);
|
|
expect(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', () => {
|
|
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', 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(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 });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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 () => {
|
|
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-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 () => {
|
|
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 });
|
|
});
|
|
|
|
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 () => {
|
|
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 () => {
|
|
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.
|
|
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.
|
|
seedUser(USER, 'https://immich.example', 'viewer-key');
|
|
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 () => {
|
|
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' });
|
|
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 () => {
|
|
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 () => {
|
|
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 () => {
|
|
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 () => {
|
|
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', () => {
|
|
seedUser(USER, 'https://immich.test', 'key-1', 0, 1);
|
|
expect(svc.getImmichCredentials(USER)!.allow_insecure_tls).toBe(true);
|
|
|
|
seedUser(USER, 'https://immich.test', 'key-1', 0, 0);
|
|
expect(svc.getImmichCredentials(USER)!.allow_insecure_tls).toBe(false);
|
|
|
|
// Anything the column should never hold reads as off, not as truthy.
|
|
seedUser(USER, 'https://immich.test', 'key-1', 0, 2);
|
|
expect(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 () => {
|
|
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]).toEqual(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]).toEqual(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.
|
|
seedUser(USER, 'https://viewer.test', 'viewer-key', 0, 0);
|
|
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]).toEqual(LAX);
|
|
}
|
|
|
|
safeFetch.mockClear();
|
|
seedUser(USER, 'https://viewer.test', 'viewer-key', 0, 1);
|
|
seedUser(20, 'https://owner.test', 'owner-key', 0, 0);
|
|
await svc.getAssetInfo(USER, 'a1', 20);
|
|
expect(safeFetch.mock.calls[0][2]).toEqual(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(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(svc.getConnectionSettings(USER).allow_insecure_tls).toBe(true);
|
|
|
|
await svc.saveImmichSettings(USER, 'https://immich.test', 'key-1', null, false);
|
|
expect(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);
|
|
const row = testDb.prepare('SELECT immich_url, immich_allow_insecure_tls FROM users WHERE id = ?').get(USER);
|
|
expect(row).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 () => {
|
|
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(svc.getConnectionSettings(USER).allow_insecure_tls).toBe(true);
|
|
|
|
await svc.saveImmichSettings(USER, 'https://photos.example.com', 'key-2', null);
|
|
expect(testDb.prepare('SELECT immich_url, immich_allow_insecure_tls FROM users WHERE id = ?').get(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(svc.getConnectionSettings(USER).allow_insecure_tls).toBe(true);
|
|
});
|
|
|
|
it('IMMICH-TLS-006: a URL the guard refuses leaves the stored switch alone', async () => {
|
|
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(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);
|
|
});
|
|
});
|