fix(immich): a server with a self-signed certificate can be connected, as the other providers can

Synology Photos, AirTrail and Dawarich each have a switch to trust a
self-signed certificate; Immich had none, so every request to such a
server failed with a bare "fetch failed" (#2475).

Immich gets the same switch, per user and off by default:

- users.immich_allow_insecure_tls (migration 243, column guarded) and a
  settings row labelled with the existing skipSSLVerification string, so
  no new translations. The settings card on desktop and phone draws it
  from that row.
- Every request to the user's Immich (status, browse, search, asset info
  and proxy, albums, upload) passes rejectUnauthorized from the switch.
  Only 1 counts as on. A shared photo follows its owner's switch, since
  it goes to the owner's server with the owner's key.
- Settings PUT and the connection test take allow_insecure_tls as a
  boolean. Absent on a save keeps the stored choice, so an older client
  cannot clear it; disconnecting turns it off again.
- A failed request names its cause, e.g. "fetch failed (self-signed
  certificate)", instead of only the undici message.
- The upload mirror gets a timeout: the journey upload waits on it.

Fresh installs also never showed "Mirror journey photos to Immich on
upload": migration 112 added that row only where the Immich provider
already existed, and on a new database the seeds create it after the
migrations. The row is now in the seeds, and migration 243 adds it where
the provider exists without it.

Wiki: Photo-Providers.md documents the switch.
This commit is contained in:
Maurice
2026-09-23 16:12:18 +02:00
parent 41b14ebc2a
commit ae80ad266d
17 changed files with 704 additions and 53 deletions
@@ -1,4 +1,4 @@
// FE-COMP-PHOTOPROVIDERS-001 to FE-COMP-PHOTOPROVIDERS-024
// FE-COMP-PHOTOPROVIDERS-001 to FE-COMP-PHOTOPROVIDERS-026
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -470,3 +470,56 @@ describe('PhotoProvidersSection – checkbox fields and failures', () => {
await screen.findByText('Connected to Immich');
});
});
// ── The Immich self-signed switch, as the server describes it (025, 026) ─────
/** The Immich card exactly as the provider rows seed it (#2475). */
const immichProvider = {
...fakeProvider,
fields: [
{ key: 'immich_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://immich.example.com', required: true, secret: false, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
{ key: 'immich_api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'API Key', required: true, secret: true, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
{ key: 'immich_allow_insecure_tls', label: 'skipSSLVerification', input_type: 'checkbox', placeholder: null, required: false, secret: false, settings_key: 'allow_insecure_tls', payload_key: 'allow_insecure_tls', sort_order: 2 },
],
};
describe('PhotoProvidersSection: Immich self-signed certificates', () => {
it('FE-COMP-PHOTOPROVIDERS-025: a stored switch shows as on under its Synology wording', async () => {
server.use(
http.get('/api/addons/immich/settings', () =>
HttpResponse.json({ immich_url: 'https://immich.lan', connected: true, auto_upload: false, allow_insecure_tls: true }),
),
);
seedMemoriesEnabled([immichProvider]);
render(<PhotoProvidersSection />);
expect(await screen.findByText('Skip SSL certificate verification')).toBeInTheDocument();
await waitFor(() => expect(checkboxToggle()).toHaveAttribute('aria-pressed', 'true'));
});
it('FE-COMP-PHOTOPROVIDERS-026: turning it on sends it with the connection test, as a boolean', async () => {
const user = userEvent.setup();
let body: Record<string, unknown> | null = null;
server.use(
http.get('/api/addons/immich/settings', () =>
HttpResponse.json({ immich_url: 'https://immich.lan', connected: false, auto_upload: false, allow_insecure_tls: false }),
),
http.post('/api/addons/immich/test', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ connected: true });
}),
);
seedMemoriesEnabled([immichProvider]);
render(<PhotoProvidersSection />);
await screen.findByDisplayValue('https://immich.lan');
await waitFor(() => expect(checkboxToggle()).toHaveAttribute('aria-pressed', 'false'));
await user.type(screen.getByPlaceholderText('API Key'), 'secret-key');
await user.click(checkboxToggle());
await user.click(screen.getByRole('button', { name: /test connection/i }));
await waitFor(() =>
expect(body).toEqual({ immich_url: 'https://immich.lan', immich_api_key: 'secret-key', allow_insecure_tls: true }),
);
});
});
+31
View File
@@ -5226,6 +5226,37 @@ function runMigrations(db: Database.Database): void {
const seated = reseatBookedNights(db);
if (seated > 0) console.log(`[DB] Seated ${seated} booked night(s) at the head of their day`);
},
/*
* Immich learns the switch Synology, AirTrail and Dawarich already have: a
* server behind a self-signed certificate can be trusted per user (#2475).
* Off by default, and only 1 counts as on.
*
* The two settings rows are the toggle for it and the auto-upload toggle
* migration 112 meant to add. That one only ran where the Immich provider
* row already existed, which on a fresh install it never did (the seeds run
* after the migrations), so fresh installs never showed the upload toggle.
* Both rows are in seeds.ts as well for the same reason; here they reach the
* installs that already have the provider row. Re-runnable.
*/
() => {
const hasColumn = db
.prepare("SELECT 1 FROM pragma_table_info('users') WHERE name = 'immich_allow_insecure_tls'")
.get();
if (!hasColumn) {
db.exec('ALTER TABLE users ADD COLUMN immich_allow_insecure_tls INTEGER NOT NULL DEFAULT 0');
}
const hasImmich = db.prepare("SELECT 1 FROM photo_providers WHERE id = 'immich'").get();
if (hasImmich) {
db.exec(`
INSERT OR IGNORE INTO photo_provider_fields
(provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order)
VALUES
('immich', 'immich_allow_insecure_tls', 'skipSSLVerification', 'checkbox', NULL, NULL, 0, 0, 'allow_insecure_tls', 'allow_insecure_tls', 2),
('immich', 'immich_auto_upload', 'immichAutoUpload', 'checkbox', NULL, NULL, 0, 0, 'auto_upload', 'auto_upload', 5)
`);
}
},
];
if (currentVersion < migrations.length) {
+2
View File
@@ -155,6 +155,8 @@ function seedAddons(db: Database.Database): void {
const providerFields = [
{ provider_id: 'immich', field_key: 'immich_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://immich.example.com', hint: null, required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
{ provider_id: 'immich', field_key: 'immich_api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'API Key', hint: null, required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
{ provider_id: 'immich', field_key: 'immich_allow_insecure_tls', label: 'skipSSLVerification', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, settings_key: 'allow_insecure_tls', payload_key: 'allow_insecure_tls', sort_order: 2 },
{ provider_id: 'immich', field_key: 'immich_auto_upload', label: 'immichAutoUpload', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, settings_key: 'auto_upload', payload_key: 'auto_upload', sort_order: 5 },
{ provider_id: 'synologyphotos', field_key: 'synology_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://synology.example.com/photo', hint: 'providerUrlHintSynology', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'synology_url', sort_order: 0 },
{ provider_id: 'synologyphotos', field_key: 'synology_username', label: 'providerUsername', input_type: 'text', placeholder: 'Username', hint: null, required: 1, secret: 0, settings_key: 'synology_username', payload_key: 'synology_username', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_password', label: 'providerPassword', input_type: 'password', placeholder: 'Password', hint: null, required: 1, secret: 1, settings_key: null, payload_key: 'synology_password', sort_order: 2 },
@@ -35,8 +35,10 @@ export class ImmichMemoriesController {
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const { immich_url, immich_api_key, auto_upload } = body;
const result = await this.memories.immichSaveSettings(user.id, immich_url, immich_api_key, getClientIp(req));
const { immich_url, immich_api_key, auto_upload, allow_insecure_tls } = body;
// Absent stays undefined and leaves the stored choice alone, so an older
// client cannot clear it by saving.
const result = await this.memories.immichSaveSettings(user.id, immich_url, immich_api_key, getClientIp(req), allow_insecure_tls);
if (!result.success) {
res.status(400).json({ error: result.error });
return;
@@ -59,11 +61,11 @@ export class ImmichMemoriesController {
@Post('test')
@HttpCode(200)
async test(@Body() body: ImmichTestDto) {
const { immich_url, immich_api_key } = body;
const { immich_url, immich_api_key, allow_insecure_tls } = body;
if (!immich_url || !immich_api_key) {
return { connected: false, error: 'URL and API key required' };
}
return this.memories.immichTestConnection(immich_url, immich_api_key);
return this.memories.immichTestConnection(immich_url, immich_api_key, allow_insecure_tls === true);
}
@Get('browse')
+75 -31
View File
@@ -1,14 +1,14 @@
import { Injectable } from '@nestjs/common';
import type { Response } from 'express';
import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { checkSsrf, safeFetch } from '../../utils/ssrfGuard';
import { checkSsrf, safeFetch, type SafeFetchOptions } from '../../utils/ssrfGuard';
import { AuditService } from '../audit/audit.service';
import { StorageService } from '../storage/storage.service';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import { DatabaseService } from '../database/database.service';
import { MemoriesAccessService } from './memories-access.service';
import { fail, handleServiceResult, isWithinLocalDayRange, pipeAsset, shiftCalendarDay, sortAssetsByTakenAtDesc, type Selection } from './memories.helpers';
import { describeFetchFailure, fail, handleServiceResult, isWithinLocalDayRange, pipeAsset, shiftCalendarDay, sortAssetsByTakenAtDesc, type Selection } from './memories.helpers';
const ALBUM_PAGE_SIZE = 1000;
const ALBUM_MAX_PAGES = 20;
@@ -23,6 +23,25 @@ const ALBUM_MAX_PAGES = 20;
* partial page for every page the caller asks for.
*/
const SEARCH_MAX_RAW_PAGES = 20;
/** A mirrored journey upload is one photo; a server that sits on it this long is not coming back. */
const UPLOAD_TIMEOUT_MS = 60_000;
/** One user's Immich connection, as every request to that server needs it. */
export interface ImmichCreds {
immich_url: string;
immich_api_key: string;
/** The user trusts a self-signed certificate on this server (#2475). */
allow_insecure_tls: boolean;
}
/**
* The TLS half of every request to a user's Immich. The certificate check is
* the only thing the switch relaxes: the SSRF guard and the DNS pinning still
* run on every hop.
*/
function tlsOptions(allowInsecureTls: boolean): SafeFetchOptions {
return { rejectUnauthorized: !allowInsecureTls };
}
/**
* Immich photo provider: credentials, connection test, timeline/search browsing,
@@ -40,12 +59,19 @@ export class ImmichService {
private readonly storage: StorageService,
) {}
getImmichCredentials(userId: number) {
const user = this.db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any;
getImmichCredentials(userId: number): ImmichCreds | null {
const user = this.db
.prepare('SELECT immich_url, immich_api_key, immich_allow_insecure_tls FROM users WHERE id = ?')
.get(userId) as { immich_url: string | null; immich_api_key: string | null; immich_allow_insecure_tls: number | null } | undefined;
if (!user?.immich_url || !user?.immich_api_key) return null;
const apiKey = decrypt_api_key(user.immich_api_key);
if (!apiKey) return null;
return { immich_url: user.immich_url as string, immich_api_key: apiKey };
return {
immich_url: user.immich_url,
immich_api_key: apiKey,
// Fail closed: anything but an explicit 1 keeps the certificate check on.
allow_insecure_tls: user.immich_allow_insecure_tls === 1,
};
}
/** Validate that an asset ID is a safe UUID-like string (no path traversal). */
@@ -73,11 +99,14 @@ export class ImmichService {
getConnectionSettings(userId: number) {
const creds = this.getImmichCredentials(userId);
const prefs = this.db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined;
const prefs = this.db
.prepare('SELECT immich_auto_upload, immich_allow_insecure_tls FROM users WHERE id = ?')
.get(userId) as { immich_auto_upload?: number; immich_allow_insecure_tls?: number } | undefined;
return {
immich_url: creds?.immich_url || '',
connected: !!(creds?.immich_url && creds?.immich_api_key),
auto_upload: !!(prefs?.immich_auto_upload),
allow_insecure_tls: prefs?.immich_allow_insecure_tls === 1,
};
}
@@ -85,11 +114,17 @@ export class ImmichService {
this.db.prepare('UPDATE users SET immich_auto_upload = ? WHERE id = ?').run(enabled ? 1 : 0, userId);
}
/**
* `allowInsecureTls` left undefined keeps the stored choice, so a client that
* does not know the switch cannot clear it by saving. Disconnecting (no URL)
* always turns it off again.
*/
async saveImmichSettings(
userId: number,
immichUrl: string | undefined,
immichApiKey: string | undefined,
clientIp: string | null
clientIp: string | null,
allowInsecureTls?: boolean,
): Promise<{ success: boolean; warning?: string; error?: string }> {
if (immichUrl) {
if (immichUrl.endsWith('/')) {
@@ -99,11 +134,16 @@ export class ImmichService {
if (!ssrf.allowed) {
return { success: false, error: `Invalid Immich URL: ${ssrf.error}` };
}
this.db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
immichUrl.trim(),
maybe_encrypt_api_key(immichApiKey),
userId
);
this.db
.prepare(
'UPDATE users SET immich_url = ?, immich_api_key = ?, immich_allow_insecure_tls = COALESCE(?, immich_allow_insecure_tls) WHERE id = ?',
)
.run(
immichUrl.trim(),
maybe_encrypt_api_key(immichApiKey),
allowInsecureTls === undefined ? null : Number(allowInsecureTls),
userId,
);
if (ssrf.isPrivate) {
this.audit.writeAudit({
userId,
@@ -117,7 +157,7 @@ export class ImmichService {
};
}
} else {
this.db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
this.db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ?, immich_allow_insecure_tls = 0 WHERE id = ?').run(
null,
maybe_encrypt_api_key(immichApiKey),
userId
@@ -130,7 +170,8 @@ export class ImmichService {
async testConnection(
immichUrl: string,
immichApiKey: string
immichApiKey: string,
allowInsecureTls = false,
): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string }; canonicalUrl?: string }> {
if (immichUrl.endsWith('/')) {
immichUrl = immichUrl.slice(0, -1);
@@ -141,7 +182,7 @@ export class ImmichService {
const resp = await safeFetch(`${immichUrl}/api/users/me`, {
headers: { 'x-api-key': immichApiKey, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
});
}, tlsOptions(allowInsecureTls));
if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` };
const data = await resp.json() as { name?: string; email?: string };
@@ -162,7 +203,7 @@ export class ImmichService {
return { connected: true, user: { name: data.name, email: data.email }, canonicalUrl };
} catch (err: unknown) {
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
return { connected: false, error: describeFetchFailure(err) };
}
}
@@ -175,12 +216,12 @@ export class ImmichService {
const resp = await safeFetch(`${creds.immich_url}/api/users/me`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
});
}, tlsOptions(creds.allow_insecure_tls));
if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` };
const data = await resp.json() as { name?: string; email?: string };
return { connected: true, user: { name: data.name, email: data.email } };
} catch (err: unknown) {
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
return { connected: false, error: describeFetchFailure(err) };
}
}
@@ -197,7 +238,7 @@ export class ImmichService {
method: 'GET',
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000) as any,
});
}, tlsOptions(creds.allow_insecure_tls));
if (!resp.ok) return { error: 'Failed to fetch from Immich', status: resp.status };
const buckets = await resp.json();
return { buckets };
@@ -214,7 +255,7 @@ export class ImmichService {
* that has to stay identical on every page of the same search.
*/
private async fetchSearchPage(
creds: { immich_url: string; immich_api_key: string },
creds: ImmichCreds,
from: string | undefined,
to: string | undefined,
page: number,
@@ -258,7 +299,7 @@ export class ImmichService {
page,
}),
signal: AbortSignal.timeout(15000) as any,
});
}, tlsOptions(creds.allow_insecure_tls));
if (!resp.ok) return { status: resp.status };
const data = await resp.json() as { assets?: { items?: any[] } };
return { items: data.assets?.items || [] };
@@ -354,7 +395,7 @@ export class ImmichService {
const resp = await safeFetch(`${creds.immich_url}/api/assets/${assetId}`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
});
}, tlsOptions(creds.allow_insecure_tls));
if (!resp.ok) return { error: 'Failed', status: resp.status };
const asset = await resp.json() as any;
return {
@@ -398,7 +439,7 @@ export class ImmichService {
const resp = await safeFetch(url, {
headers: { 'x-api-key': creds.immich_api_key },
signal: AbortSignal.timeout(10000) as any,
});
}, tlsOptions(creds.allow_insecure_tls));
if (!resp.ok) return { error: 'Upstream error', status: resp.status };
const contentType = resp.headers.get('content-type') || 'image/jpeg';
const bytes = Buffer.from(await resp.arrayBuffer());
@@ -459,7 +500,7 @@ export class ImmichService {
timeout = 30000;
}
await pipeAsset(url, response, headers, timeout ? AbortSignal.timeout(timeout) : undefined, cacheControl);
await pipeAsset(url, response, headers, timeout ? AbortSignal.timeout(timeout) : undefined, cacheControl, tlsOptions(creds.allow_insecure_tls));
}
// ── Albums ──────────────────────────────────────────────────────────────────
@@ -476,11 +517,11 @@ export class ImmichService {
safeFetch(`${creds.immich_url}/api/albums`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
}),
}, tlsOptions(creds.allow_insecure_tls)),
safeFetch(`${creds.immich_url}/api/albums?shared=true`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
}),
}, tlsOptions(creds.allow_insecure_tls)),
]);
if (!ownResp.ok) return { error: 'Failed to fetch albums', status: ownResp.status };
const ownAlbums = await ownResp.json() as any[];
@@ -534,13 +575,13 @@ export class ImmichService {
* the search path is correct. A version probe with a wrong boundary would not be.
*/
private async fetchAlbumAssets(
creds: { immich_url: string; immich_api_key: string },
creds: ImmichCreds,
albumId: string,
): Promise<{ assets?: any[]; status?: number }> {
const resp = await safeFetch(`${creds.immich_url}/api/albums/${albumId}`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000) as any,
});
}, tlsOptions(creds.allow_insecure_tls));
if (!resp.ok) return { status: resp.status };
const albumData = await resp.json() as { assets?: any[] };
@@ -557,7 +598,7 @@ export class ImmichService {
* it Immich omits `exifInfo` entirely and every photo's city/country goes null.
*/
private async fetchAlbumAssetsViaSearch(
creds: { immich_url: string; immich_api_key: string },
creds: ImmichCreds,
albumId: string,
): Promise<{ assets?: any[]; status?: number }> {
const all: any[] = [];
@@ -575,7 +616,7 @@ export class ImmichService {
page,
}),
signal: AbortSignal.timeout(15000) as any,
});
}, tlsOptions(creds.allow_insecure_tls));
if (!resp.ok) return { status: resp.status };
const data = await resp.json() as { assets?: { items?: any[] } };
@@ -710,7 +751,10 @@ export class ImmichService {
'Content-Length': String(body.length),
},
body,
});
// The journey upload waits on this mirror, so a server that never
// answers must not hold the upload open with it.
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
}, tlsOptions(creds.allow_insecure_tls));
if (res.ok) {
const data = await res.json() as { id?: string };
@@ -213,6 +213,24 @@ export type AssetInfo = {
fileName?: string | null;
}
/**
* Why a request to a provider never got an answer, worded for the settings card.
*
* undici rejects with a bare "fetch failed" and keeps the reason on `cause`
* (an unknown host, a refused connection, a self-signed certificate), so the
* message alone gave the user nothing to act on (#2475). The first cause that
* says something is appended; an error without one reads exactly as before.
*/
export function describeFetchFailure(err: unknown): string {
if (!(err instanceof Error)) return 'Connection failed';
let cause: unknown = err.cause;
for (let depth = 0; depth < 3 && cause instanceof Error; depth++) {
if (cause.message && cause.message !== err.message) return `${err.message} (${cause.message})`;
cause = cause.cause;
}
return err.message;
}
/**
* Proxy an upstream asset straight to the client.
*
+4 -4
View File
@@ -67,8 +67,8 @@ export class MemoriesService {
return this.immich.getConnectionSettings(userId);
}
immichSaveSettings(userId: number, immichUrl: string | undefined, immichApiKey: string | undefined, clientIp: string | null) {
return this.immich.saveImmichSettings(userId, immichUrl, immichApiKey, clientIp);
immichSaveSettings(userId: number, immichUrl: string | undefined, immichApiKey: string | undefined, clientIp: string | null, allowInsecureTls?: boolean) {
return this.immich.saveImmichSettings(userId, immichUrl, immichApiKey, clientIp, allowInsecureTls);
}
immichSetAutoUpload(userId: number, enabled: boolean): void {
@@ -79,8 +79,8 @@ export class MemoriesService {
return this.immich.getConnectionStatus(userId);
}
immichTestConnection(immichUrl: string, immichApiKey: string) {
return this.immich.testConnection(immichUrl, immichApiKey);
immichTestConnection(immichUrl: string, immichApiKey: string, allowInsecureTls: boolean) {
return this.immich.testConnection(immichUrl, immichApiKey, allowInsecureTls);
}
immichBrowseTimeline(userId: number) {
+31
View File
@@ -67,6 +67,7 @@ import { SynologyService } from '../../src/nest/memories/synology.service';
import { MemoriesAccessService } from '../../src/nest/memories/memories-access.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
const BASE = '/api/integrations/memories';
const UNIFIED = `${BASE}/unified`;
@@ -87,6 +88,9 @@ describe('Memories e2e (real auth guard + temp SQLite)', () => {
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
// Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate
// by metatype, exactly as they do under buildApp().
nest.useGlobalPipes(new ZodValidationPipe());
await nest.init();
return nest;
}
@@ -209,6 +213,33 @@ describe('Memories e2e (real auth guard + temp SQLite)', () => {
expect(bad.body).toEqual({ error: 'Invalid Immich URL: bad' });
});
it('PUT settings hands the self-signed switch to the save, and leaves it undefined when absent (#2475)', async () => {
immich.saveImmichSettings.mockResolvedValue({ success: true });
const on = await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k', allow_insecure_tls: true });
expect(on.status).toBe(200);
expect(immich.saveImmichSettings).toHaveBeenLastCalledWith(1, 'https://x', 'k', expect.anything(), true);
await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k' });
expect(immich.saveImmichSettings).toHaveBeenLastCalledWith(1, 'https://x', 'k', expect.anything(), undefined);
});
it('400 PUT settings and /test with a switch value that is not a boolean, before the service runs', async () => {
const put = await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k', allow_insecure_tls: 'true' });
expect(put.status).toBe(400);
const test = await request(server).post(`${IMMICH}/test`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k', allow_insecure_tls: 1 });
expect(test.status).toBe(400);
expect(immich.saveImmichSettings).not.toHaveBeenCalled();
expect(immich.testConnection).not.toHaveBeenCalled();
});
it('200 /test probes with the switch the form carries', async () => {
immich.testConnection.mockResolvedValue({ connected: false, error: 'HTTP 404' });
const res = await request(server).post(`${IMMICH}/test`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k', allow_insecure_tls: true });
expect(res.status).toBe(200);
expect(res.body).toEqual({ connected: false, error: 'HTTP 404' });
expect(immich.testConnection).toHaveBeenCalledWith('https://x', 'k', true);
});
it('CRITICAL: 200 /status with { connected: false } on failure', async () => {
immich.getConnectionStatus.mockResolvedValue({ connected: false, error: 'Not configured' });
const res = await request(server).get(`${IMMICH}/status`).set('Cookie', sessionCookie(1));
@@ -1126,3 +1126,107 @@ describe('Immich testConnection canonical URL detection', () => {
expect(res.body.canonicalUrl).toBeUndefined();
});
});
// ── Self-signed certificates (#2475) ─────────────────────────────────────────
describe('Immich self-signed certificate switch (#2475)', () => {
const LAX = { rejectUnauthorized: false };
const STRICT = { rejectUnauthorized: true };
/** Only what the service and the asset proxy read off a response. */
function okJson(json: unknown): Response {
return { ok: true, status: 200, url: '', headers: { get: () => null }, json: async () => json, body: null } as unknown as Response;
}
function allowInsecureTls(userId: number): number {
return (testDb.prepare('SELECT immich_allow_insecure_tls AS v FROM users WHERE id = ?').get(userId) as { v: number }).v;
}
beforeEach(() => {
// Earlier cases leave a fixed response behind; these need a plain answer per path.
vi.mocked(safeFetch).mockReset();
vi.mocked(safeFetch).mockImplementation(async (url: string) =>
String(url).includes('/api/search/metadata') ? okJson({ assets: { items: [] } }) : okJson({}),
);
});
it('IMMICH-102: PUT /settings stores the switch and GET /settings reports it', async () => {
const { user } = createUser(testDb);
const put = await request(app)
.put(`${IMMICH}/settings`)
.set('Cookie', authCookie(user.id))
.send({ immich_url: 'https://immich.example.com', immich_api_key: 'k', allow_insecure_tls: true });
expect(put.status).toBe(200);
const get = await request(app).get(`${IMMICH}/settings`).set('Cookie', authCookie(user.id));
expect(get.status).toBe(200);
expect(get.body.allow_insecure_tls).toBe(true);
});
it('IMMICH-103: a save without the switch keeps it, and disconnecting clears it', async () => {
const { user } = createUser(testDb);
const put = (body: Record<string, unknown>) =>
request(app).put(`${IMMICH}/settings`).set('Cookie', authCookie(user.id)).send(body);
await put({ immich_url: 'https://immich.example.com', immich_api_key: 'k', allow_insecure_tls: true });
expect(allowInsecureTls(user.id)).toBe(1);
// What a client that predates the switch sends.
await put({ immich_url: 'https://immich.example.com', immich_api_key: 'k' });
expect(allowInsecureTls(user.id)).toBe(1);
await put({ immich_url: '', immich_api_key: 'k', allow_insecure_tls: true });
expect(allowInsecureTls(user.id)).toBe(0);
});
it('IMMICH-104: browse, search and the thumbnail proxy follow the stored switch', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
addTripPhoto(testDb, trip.id, user.id, 'asset-tls', 'immich', { shared: false });
const cookie = authCookie(user.id);
const hitEveryPath = async () => {
expect((await request(app).get(`${IMMICH}/browse`).set('Cookie', cookie)).status).toBe(200);
expect((await request(app).post(`${IMMICH}/search`).set('Cookie', cookie).send({ page: 1, size: 10 })).status).toBe(200);
expect((await request(app).get(`${IMMICH}/assets/${trip.id}/asset-tls/${user.id}/thumbnail`).set('Cookie', cookie)).status).toBe(200);
};
testDb.prepare('UPDATE users SET immich_allow_insecure_tls = 1 WHERE id = ?').run(user.id);
await hitEveryPath();
expect(vi.mocked(safeFetch).mock.calls).toHaveLength(3);
for (const call of vi.mocked(safeFetch).mock.calls) expect(call[2]).toEqual(LAX);
vi.mocked(safeFetch).mockClear();
testDb.prepare('UPDATE users SET immich_allow_insecure_tls = 0 WHERE id = ?').run(user.id);
await hitEveryPath();
expect(vi.mocked(safeFetch).mock.calls).toHaveLength(3);
for (const call of vi.mocked(safeFetch).mock.calls) expect(call[2]).toEqual(STRICT);
});
it('IMMICH-105: POST /test probes with the switch in the form, and refuses a value that is not a boolean', async () => {
const { user } = createUser(testDb);
const test = (allow_insecure_tls: unknown) =>
request(app)
.post(`${IMMICH}/test`)
.set('Cookie', authCookie(user.id))
.send({ immich_url: 'https://immich.example.com', immich_api_key: 'k', allow_insecure_tls });
const lax = await test(true);
expect(lax.status).toBe(200);
expect(lax.body.connected).toBe(true);
expect(vi.mocked(safeFetch).mock.calls[0][2]).toEqual(LAX);
const strict = await test(false);
expect(strict.status).toBe(200);
expect(vi.mocked(safeFetch).mock.calls[1][2]).toEqual(STRICT);
// Only a real boolean: the form has always sent one, and anything else
// must not be read as either answer.
for (const value of ['true', 'yes']) {
expect((await test(value)).status).toBe(400);
}
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,100 @@
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { runSeeds } from '../../../src/db/seeds';
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it } from 'vitest';
// The Immich self-signed switch and the two settings rows are pinned at schema
// version 243 (#2475). Later migrations are appended after it, so a case that
// replays it rewinds to just before it; the steps after it are idempotent.
const IMMICH_TLS_VERSION = 243;
type FieldRow = { field_key: string; input_type: string; settings_key: string | null; payload_key: string | null; sort_order: number };
function immichFields(db: Database.Database): FieldRow[] {
return db
.prepare(
"SELECT field_key, input_type, settings_key, payload_key, sort_order FROM photo_provider_fields WHERE provider_id = 'immich' ORDER BY sort_order",
)
.all() as FieldRow[];
}
const EXPECTED_FIELDS: FieldRow[] = [
{ field_key: 'immich_url', input_type: 'url', settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
{ field_key: 'immich_api_key', input_type: 'password', settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
{ field_key: 'immich_allow_insecure_tls', input_type: 'checkbox', settings_key: 'allow_insecure_tls', payload_key: 'allow_insecure_tls', sort_order: 2 },
{ field_key: 'immich_auto_upload', input_type: 'checkbox', settings_key: 'auto_upload', payload_key: 'auto_upload', sort_order: 5 },
];
function replay(db: Database.Database): void {
db.prepare('UPDATE schema_version SET version = ?').run(IMMICH_TLS_VERSION - 1);
runMigrations(db);
}
describe('Immich provider fields and the self-signed switch (#2475)', () => {
let db: Database.Database;
afterEach(() => db?.close());
it('a fresh install shows every Immich setting, the auto-upload toggle included', () => {
// database.ts runs schema, migrations, seeds in that order. On an empty
// database the migrations find no Immich provider row yet, so the seeds
// are what has to bring the rows along.
db = new Database(':memory:');
createTables(db);
runMigrations(db);
// An existing user keeps the admin seeding out of this.
db.exec("INSERT INTO users (username, email, password_hash) VALUES ('someone', 'someone@example.test', 'x')");
runSeeds(db);
expect(immichFields(db)).toEqual(EXPECTED_FIELDS);
const label = db
.prepare("SELECT label FROM photo_provider_fields WHERE provider_id = 'immich' AND field_key = 'immich_allow_insecure_tls'")
.get();
// The Synology label, so every locale already has the words.
expect(label).toEqual({ label: 'skipSSLVerification' });
});
it('the switch is off for every user until they turn it on', () => {
db = new Database(':memory:');
createTables(db);
runMigrations(db);
db.exec("INSERT INTO users (username, email, password_hash) VALUES ('someone', 'someone@example.test', 'x')");
expect(db.prepare('SELECT immich_allow_insecure_tls AS v FROM users').get()).toEqual({ v: 0 });
});
it('an install that already had the Immich provider gets both rows once, and a replay changes nothing', () => {
db = new Database(':memory:');
createTables(db);
runMigrations(db);
// What an upgraded fresh install looked like: the seeds of an older release
// added the provider with only its URL and key.
db.exec(`
INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES ('immich', 'Immich', 'Immich photo provider', 'Image', 0, 0);
INSERT INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) VALUES
('immich', 'immich_url', 'providerUrl', 'url', 'https://immich.example.com', 1, 0, 'immich_url', 'immich_url', 0),
('immich', 'immich_api_key', 'providerApiKey', 'password', 'API Key', 1, 1, NULL, 'immich_api_key', 1);
`);
replay(db);
expect(immichFields(db)).toEqual(EXPECTED_FIELDS);
replay(db);
expect(immichFields(db)).toEqual(EXPECTED_FIELDS);
});
it('a replay keeps what a user already chose', () => {
db = new Database(':memory:');
createTables(db);
runMigrations(db);
db.exec(
"INSERT INTO users (username, email, password_hash, immich_allow_insecure_tls) VALUES ('someone', 'someone@example.test', 'x', 1)",
);
replay(db);
expect(db.prepare('SELECT immich_allow_insecure_tls AS v FROM users').get()).toEqual({ v: 1 });
});
});
+168 -7
View File
@@ -58,9 +58,9 @@ const svc = new ImmichService(dbs, audit as unknown as AuditService, access as u
const USER = 1;
function seedUser(id: number, url: string | null, key: string | null, autoUpload = 0): void {
testDb.prepare("INSERT OR REPLACE INTO users (id, username, email, password_hash, immich_url, immich_api_key, immich_auto_upload) VALUES (?, ?, ?, 'x', ?, ?, ?)")
.run(id, `u${id}`, `u${id}@example.test`, url, key, autoUpload);
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. */
@@ -111,7 +111,7 @@ describe('getImmichCredentials', () => {
});
it('IMMICH-005: returns the decrypted pair otherwise', () => {
expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://immich.test', immich_api_key: 'key-1' });
expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://immich.test', immich_api_key: 'key-1', allow_insecure_tls: false });
});
});
@@ -125,12 +125,12 @@ describe('isValidAssetId', () => {
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 });
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 });
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', () => {
@@ -155,7 +155,7 @@ describe('saveImmichSettings', () => {
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' });
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 () => {
@@ -725,3 +725,164 @@ describe('uploadToImmich', () => {
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-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);
});
});
@@ -242,7 +242,7 @@ describe('ImmichMemoriesController (parity with /api/integrations/memories/immic
const svc = makeService({ immichSaveSettings, immichSetAutoUpload });
const res = makeRes();
await new ImmichMemoriesController(svc).putSettings(user, { immich_url: 'x', immich_api_key: 'k', auto_upload: true }, req, res);
expect(immichSaveSettings).toHaveBeenCalledWith(7, 'x', 'k', '1.2.3.4');
expect(immichSaveSettings).toHaveBeenCalledWith(7, 'x', 'k', '1.2.3.4', undefined);
expect(immichSetAutoUpload).toHaveBeenCalledWith(7, true);
expect(res.json).toHaveBeenCalledWith({ success: true });
});
@@ -255,6 +255,24 @@ describe('ImmichMemoriesController (parity with /api/integrations/memories/immic
expect(immichSetAutoUpload).not.toHaveBeenCalled();
});
it('hands the self-signed switch to the save both ways (#2475)', async () => {
const immichSaveSettings = vi.fn().mockResolvedValue({ success: true });
const svc = makeService({ immichSaveSettings });
const controller = new ImmichMemoriesController(svc);
await controller.putSettings(user, { immich_url: 'x', immich_api_key: 'k', allow_insecure_tls: true }, req, makeRes());
await controller.putSettings(user, { immich_url: 'x', immich_api_key: 'k', allow_insecure_tls: false }, req, makeRes());
expect(immichSaveSettings.mock.calls.map((call) => call[4])).toEqual([true, false]);
});
it('leaves the switch undefined when the body does not carry it, so the stored choice stays', async () => {
const immichSaveSettings = vi.fn().mockResolvedValue({ success: true });
const svc = makeService({ immichSaveSettings });
await new ImmichMemoriesController(svc).putSettings(user, { immich_url: 'x', immich_api_key: 'k' }, req, makeRes());
expect(immichSaveSettings).toHaveBeenCalledWith(7, 'x', 'k', '1.2.3.4', undefined);
});
it('returns the warning when the save carries one', async () => {
const svc = makeService({ immichSaveSettings: vi.fn().mockResolvedValue({ success: true, warning: 'Unverified TLS' }) });
const res = makeRes();
@@ -289,7 +307,19 @@ describe('ImmichMemoriesController (parity with /api/integrations/memories/immic
const immichTestConnection = vi.fn().mockResolvedValue({ connected: true });
const svc = makeService({ immichTestConnection });
expect(await new ImmichMemoriesController(svc).test({ immich_url: 'u', immich_api_key: 'k' })).toEqual({ connected: true });
expect(immichTestConnection).toHaveBeenCalledWith('u', 'k');
// No switch in the body tests with the certificate check on.
expect(immichTestConnection).toHaveBeenCalledWith('u', 'k', false);
});
it('tests with the self-signed switch the form carries (#2475)', async () => {
const immichTestConnection = vi.fn().mockResolvedValue({ connected: true });
const svc = makeService({ immichTestConnection });
const controller = new ImmichMemoriesController(svc);
await controller.test({ immich_url: 'u', immich_api_key: 'k', allow_insecure_tls: true });
await controller.test({ immich_url: 'u', immich_api_key: 'k', allow_insecure_tls: false });
expect(immichTestConnection.mock.calls.map((call) => call[2])).toEqual([true, false]);
});
});
@@ -10,6 +10,7 @@
import { describe, it, expect } from 'vitest';
import {
dayStartEpochSeconds,
describeFetchFailure,
isWithinLocalDayRange,
shiftCalendarDay,
sortAssetsByTakenAtDesc,
@@ -149,3 +150,29 @@ describe('dayStartEpochSeconds', () => {
expect(Number.isNaN(dayStartEpochSeconds('nope'))).toBe(true);
});
});
describe('describeFetchFailure', () => {
it('MEM-FETCH-001: appends the reason undici keeps on cause', () => {
const err = new TypeError('fetch failed', { cause: new Error('self-signed certificate') });
expect(describeFetchFailure(err)).toBe('fetch failed (self-signed certificate)');
});
it('MEM-FETCH-002: an error without a cause reads exactly as before', () => {
expect(describeFetchFailure(new Error('ECONNREFUSED'))).toBe('ECONNREFUSED');
});
it('MEM-FETCH-003: looks past a silent or repeated cause to the first one that says something', () => {
const deep = new Error('fetch failed', {
cause: new Error('', { cause: new Error('fetch failed', { cause: new Error('getaddrinfo ENOTFOUND immich.lan') }) }),
});
expect(describeFetchFailure(deep)).toBe('fetch failed (getaddrinfo ENOTFOUND immich.lan)');
});
it('MEM-FETCH-004: a cause that is not an Error is ignored', () => {
expect(describeFetchFailure(new Error('fetch failed', { cause: 'DEPTH_ZERO_SELF_SIGNED_CERT' }))).toBe('fetch failed');
});
it('MEM-FETCH-005: a rejection that is not an Error falls back to the old wording', () => {
expect(describeFetchFailure('boom')).toBe('Connection failed');
});
});
@@ -120,8 +120,8 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () =>
svc.immichGetConnectionSettings(7);
expect(immich.getConnectionSettings).toHaveBeenCalledWith(7);
await svc.immichSaveSettings(7, 'u', 'k', '1.2.3.4');
expect(immich.saveImmichSettings).toHaveBeenCalledWith(7, 'u', 'k', '1.2.3.4');
await svc.immichSaveSettings(7, 'u', 'k', '1.2.3.4', true);
expect(immich.saveImmichSettings).toHaveBeenCalledWith(7, 'u', 'k', '1.2.3.4', true);
svc.immichSetAutoUpload(7, true);
expect(immich.setImmichAutoUpload).toHaveBeenCalledWith(7, true);
@@ -129,8 +129,8 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () =>
await svc.immichGetConnectionStatus(7);
expect(immich.getConnectionStatus).toHaveBeenCalledWith(7);
await svc.immichTestConnection('u', 'k');
expect(immich.testConnection).toHaveBeenCalledWith('u', 'k');
await svc.immichTestConnection('u', 'k', true);
expect(immich.testConnection).toHaveBeenCalledWith('u', 'k', true);
await svc.immichBrowseTimeline(7);
expect(immich.browseTimeline).toHaveBeenCalledWith(7);
@@ -3,6 +3,7 @@ import {
createAlbumLinkSchema,
immichSearchSchema,
immichSettingsSchema,
immichTestSchema,
setTripPhotoSharingSchema,
synologySearchSchema,
synologySettingsSchema,
@@ -29,6 +30,32 @@ describe('immich contracts', () => {
}
});
it('accepts the self-signed switch as a boolean, on save and on test (#2475)', () => {
for (const allow_insecure_tls of [true, false]) {
expect(immichSettingsSchema.safeParse({ allow_insecure_tls }).success).toBe(true);
expect(
immichTestSchema.safeParse({
immich_url: 'https://immich.example.org',
immich_api_key: 'k',
allow_insecure_tls,
}).success,
).toBe(true);
}
});
it('leaves the switch optional, since an older client never sends it', () => {
const parsed = immichSettingsSchema.safeParse({ immich_url: 'https://immich.example.org', immich_api_key: 'k' });
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data.allow_insecure_tls).toBeUndefined();
});
it('refuses a switch value that is not a boolean, its string included', () => {
for (const allow_insecure_tls of ['true', 'yes', 1, null]) {
expect(immichSettingsSchema.safeParse({ allow_insecure_tls }).success).toBe(false);
expect(immichTestSchema.safeParse({ allow_insecure_tls }).success).toBe(false);
}
});
it('accepts search paging as numbers OR strings (the controller does Number(x) || fallback)', () => {
expect(immichSearchSchema.safeParse({ page: 2, size: 50 }).success).toBe(true);
expect(immichSearchSchema.safeParse({ page: '2', size: '50' }).success).toBe(true);
+11
View File
@@ -40,17 +40,28 @@ const utcOffsetMinutes = looseNumber;
// ── Immich ────────────────────────────────────────────────────────────────
/**
* Trust a self-signed certificate on the Immich server (#2475). Absent means
* "leave the stored choice as it is", so a client that predates the switch
* cannot turn it off by saving. A plain boolean: the field is new, and the
* settings form has always sent its checkboxes as booleans, so none of the
* string leniency `synology_skip_ssl` carries is owed here.
*/
const immichAllowInsecureTls = z.boolean().optional();
export const immichSettingsSchema = z.looseObject({
immich_url: optionalText,
immich_api_key: optionalText,
// Applied only when it is a real boolean (`typeof auto_upload === 'boolean'`),
// so anything else is accepted and ignored, exactly as before.
auto_upload: z.unknown().optional(),
allow_insecure_tls: immichAllowInsecureTls,
});
export const immichTestSchema = z.looseObject({
immich_url: optionalText,
immich_api_key: optionalText,
allow_insecure_tls: immichAllowInsecureTls,
});
export const immichSearchSchema = z.looseObject({
+10
View File
@@ -29,10 +29,20 @@ Go to **Settings → Integrations**. Each enabled provider gets its own settings
|-------|----------|-------|
| Server URL | Yes | Full URL of your Immich instance, e.g. `https://immich.example.com` |
| API Key | Yes | Stored encrypted; never returned to the browser after saving |
| Skip SSL certificate verification | No | Checkbox; lets TREK connect to an Immich server with a self-signed certificate. Off by default |
| Mirror journey photos to Immich on upload | No | Checkbox; when enabled, photos you upload in TREK are also pushed to your Immich library |
Enter the full URL of your Immich instance and an Immich API key. The API key is stored encrypted on the TREK server and is never returned to the browser after it is saved.
#### Self-signed certificates
If your Immich server uses a self-signed certificate (for example behind a reverse proxy on your home network), the connection test fails with `fetch failed (self-signed certificate)`. Turn on **Skip SSL certificate verification**, enter the API key again and test or save.
- The setting is per user and only applies to your own Immich connection. Disconnecting Immich turns it off again.
- Only the certificate check is skipped. The connection is still encrypted, but TREK can no longer tell your server from an impostor on the same network, and your API key travels over that connection. Prefer a certificate from a trusted CA where you can.
- Photos you share on a trip are loaded through your connection, so your setting applies when other trip members view them.
- A server on a local or private address still needs internal network access on the TREK server. See [Internal-Network-Access](Internal-Network-Access).
#### Required API key permissions
When generating the API key in Immich (**Account Settings → API Keys**), grant only the scopes TREK actually uses: