Files
TREK/server/tests/integration/memories-immich.test.ts
T
Maurice a910c2a837 fix: follow-ups from the review of the price, Immich, moving day and import fixes
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.
2026-09-23 17:41:26 +02:00

1239 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Immich-specific integration tests (IMMICH-030 – IMMICH-070).
* Covers status, test-connection, browse, search, asset proxy, access control,
* and albums — everything NOT covered by the existing immich.test.ts.
*
* safeFetch is mocked to return fake Immich API responses based on URL patterns.
* No real HTTP calls are made.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
const { testDb, dbMock, immichState } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
// Mutable fixture driving the fake Immich responses. Tests may override
// `albumAssetPages` to exercise pagination, and read `searchCalls` to assert
// on the request bodies TREK sends to POST /api/search/metadata.
const state: {
albumAssets: any[];
albumAssetPages: any[][] | null;
searchCalls: any[];
/** Immich v2 embeds `assets` in the album detail body; v3 does not (#1492). */
albumDetailHasAssets: boolean;
} = {
albumAssets: [],
albumAssetPages: null,
searchCalls: [],
albumDetailHasAssets: false,
};
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock, immichState: state };
});
/**
* Default contents of `album-uuid-1` as Immich returns them from
* POST /api/search/metadata. Two visible assets plus two hidden ones (Live Photo
* motion parts): `visibility: 'hidden'` is the modern marker, `isVisible: false`
* the legacy one. Both must be filtered out of the picker (#1474).
*/
const DEFAULT_ALBUM_ASSETS = [
{ id: 'asset-sync-1', type: 'IMAGE', fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Paris', country: 'France', latitude: 48.8584, longitude: 2.2945 } },
{ id: 'asset-sync-2', type: 'VIDEO', fileCreatedAt: '2024-06-02T10:00:00.000Z', exifInfo: { city: 'Lyon', country: 'France' } },
{ id: 'asset-hidden', type: 'VIDEO', fileCreatedAt: '2024-06-03T10:00:00.000Z', visibility: 'hidden' },
{ id: 'asset-legacy-hidden', type: 'VIDEO', fileCreatedAt: '2024-06-04T10:00:00.000Z', isVisible: false },
];
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
SESSION_DURATION: '24h',
SESSION_DURATION_MS: 86400000,
SESSION_DURATION_SECONDS: 86400,
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
// ── SSRF guard mock — routes all Immich API calls to fake responses ───────────
vi.mock('../../src/utils/ssrfGuard', async () => {
const actual = await vi.importActual<typeof import('../../src/utils/ssrfGuard')>('../../src/utils/ssrfGuard');
function makeFakeImmichFetch(url: string, init?: any) {
const u = typeof url === 'string' ? url : String(url);
// /api/users/me — used by status + test-connection
if (u.includes('/api/users/me')) {
return Promise.resolve({
ok: true, status: 200,
headers: { get: (h: string) => h === 'content-type' ? 'application/json' : null },
json: () => Promise.resolve({ name: 'Test User', email: 'test@immich.local' }),
body: null,
});
}
// /api/timeline/buckets — browse
if (u.includes('/api/timeline/buckets')) {
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => null },
json: () => Promise.resolve([{ timeBucket: '2024-01-01T00:00:00.000Z', count: 3 }]),
body: null,
});
}
// /api/search/metadata — timeline search AND (since Immich v3) album contents
if (u.includes('/api/search/metadata')) {
const body = init?.body ? JSON.parse(init.body) : {};
immichState.searchCalls.push(body);
// Album query: Immich v3 removed AlbumResponseDto.assets, so album
// contents are fetched via an albumIds-filtered metadata search.
if (body.albumIds?.length) {
const pages = immichState.albumAssetPages ?? [immichState.albumAssets];
const items = pages[(body.page ?? 1) - 1] ?? [];
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => null },
json: () => Promise.resolve({ assets: { items } }),
body: null,
});
}
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => null },
json: () => Promise.resolve({
assets: {
items: [
{ id: 'asset-search-1', fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Paris', country: 'France', latitude: 48.8566, longitude: 2.3522 } },
],
},
}),
body: null,
});
}
// /api/assets/:id/thumbnail — thumbnail proxy
if (u.includes('/thumbnail')) {
const imageBytes = Buffer.from('fake-thumbnail-data');
return Promise.resolve({
ok: true, status: 200,
headers: { get: (h: string) => h === 'content-type' ? 'image/webp' : null },
body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }),
});
}
// /api/assets/:id/original — original proxy
if (u.includes('/original')) {
const imageBytes = Buffer.from('fake-original-data');
return Promise.resolve({
ok: true, status: 200,
headers: { get: (h: string) => h === 'content-type' ? 'image/jpeg' : null },
body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }),
});
}
// /api/assets/:id — asset info
if (/\/api\/assets\/[^/]+$/.test(u)) {
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => null },
json: () => Promise.resolve({
id: 'asset-info-1',
fileCreatedAt: '2024-06-01T10:00:00.000Z',
originalFileName: 'photo.jpg',
exifInfo: {
exifImageWidth: 4032, exifImageHeight: 3024,
make: 'Apple', model: 'iPhone 15',
lensModel: null, focalLength: 5.1, fNumber: 1.8,
exposureTime: '1/500', iso: 100,
city: 'Paris', state: 'Île-de-France', country: 'France',
latitude: 48.8566, longitude: 2.3522,
fileSizeInByte: 2048000,
},
}),
body: null,
});
}
// /api/albums — list albums (owned and shared?=true variant)
if (/\/api\/albums(\?.*)?$/.test(u)) {
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => null },
json: () => Promise.resolve([
{ id: 'album-uuid-1', albumName: 'Vacation 2024', assetCount: 42, startDate: '2024-06-01', endDate: '2024-06-14', albumThumbnailAssetId: null },
]),
body: null,
});
}
// /api/albums/:id — album detail.
// Immich v3 removed the `assets` property from AlbumResponseDto (#1492).
// Defaults to the v3 shape; `albumDetailHasAssets` models a v2 server.
if (/\/api\/albums\//.test(u)) {
const base: any = { id: 'album-uuid-1', albumName: 'Vacation 2024', assetCount: 42 };
if (immichState.albumDetailHasAssets) base.assets = immichState.albumAssets;
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => null },
json: () => Promise.resolve(base),
body: null,
});
}
// fallback — unexpected call
return Promise.reject(new Error(`Unexpected safeFetch call: ${u}`));
}
return {
...actual,
checkSsrf: vi.fn().mockImplementation(async (rawUrl: string) => {
try {
const url = new URL(rawUrl);
const h = url.hostname;
if (h === '127.0.0.1' || h === '::1' || h === 'localhost') {
return { allowed: false, isPrivate: true, error: 'Loopback not allowed' };
}
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h)) {
return { allowed: false, isPrivate: true, error: 'Private IP not allowed' };
}
return { allowed: true, isPrivate: false, resolvedIp: '93.184.216.34' };
} catch {
return { allowed: false, isPrivate: false, error: 'Invalid URL' };
}
}),
safeFetch: vi.fn().mockImplementation(makeFakeImmichFetch),
};
});
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb, resetRateLimits, setAddonEnabled } from '../helpers/test-db';
import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink, setImmichCredentials } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { safeFetch } from '../../src/utils/ssrfGuard';
let nestApp: INestApplication;
let app: Application;
const IMMICH = '/api/integrations/memories/immich';
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
resetRateLimits(nestApp);
// Providers only count as enabled under an enabled journey addon (migration 84 seeds it off).
setAddonEnabled(testDb, 'journey', true);
immichState.albumAssets = DEFAULT_ALBUM_ASSETS.map((a) => ({ ...a }));
immichState.albumAssetPages = null;
immichState.searchCalls = [];
immichState.albumDetailHasAssets = false; // default: Immich v3
});
afterAll(async () => {
await nestApp.close();
testDb.close();
});
// ── Connection status ─────────────────────────────────────────────────────────
describe('Immich connection status', () => {
it('IMMICH-030 — GET /status when not configured returns { connected: false }', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get(`${IMMICH}/status`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.connected).toBe(false);
});
it('IMMICH-031 — GET /status when configured returns connected + user info', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
const res = await request(app)
.get(`${IMMICH}/status`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.connected).toBe(true);
expect(res.body.user).toMatchObject({ name: 'Test User', email: 'test@immich.local' });
});
});
// ── Test connection ───────────────────────────────────────────────────────────
describe('Immich test connection', () => {
it('IMMICH-032 — POST /test with missing fields returns { connected: false }', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post(`${IMMICH}/test`)
.set('Cookie', authCookie(user.id))
.send({ immich_url: 'https://immich.example.com' }); // missing api_key
expect(res.status).toBe(200);
expect(res.body.connected).toBe(false);
});
it('IMMICH-033 — POST /test with valid credentials returns { connected: true }', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post(`${IMMICH}/test`)
.set('Cookie', authCookie(user.id))
.send({ immich_url: 'https://immich.example.com', immich_api_key: 'valid-key' });
expect(res.status).toBe(200);
expect(res.body.connected).toBe(true);
expect(res.body.user).toBeDefined();
});
});
// ── Browse & Search ───────────────────────────────────────────────────────────
describe('Immich browse and search', () => {
it('IMMICH-040 — GET /browse when not configured returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get(`${IMMICH}/browse`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
});
it('IMMICH-041 — GET /browse returns timeline buckets', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
const res = await request(app)
.get(`${IMMICH}/browse`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.buckets)).toBe(true);
expect(res.body.buckets.length).toBeGreaterThan(0);
});
it('IMMICH-042 — POST /search returns mapped assets with hasMore flag', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
const res = await request(app)
.post(`${IMMICH}/search`)
.set('Cookie', authCookie(user.id))
.send({ page: 1, size: 50 });
expect(res.status).toBe(200);
expect(Array.isArray(res.body.assets)).toBe(true);
expect(res.body.assets[0]).toMatchObject({ id: 'asset-search-1', city: 'Paris', country: 'France', lat: 48.8566, lng: 2.3522 });
expect(typeof res.body.hasMore).toBe('boolean');
expect(immichState.searchCalls[0]).toMatchObject({ withExif: true });
});
it('IMMICH-043 — POST /search when upstream throws returns 502', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
vi.mocked(safeFetch).mockRejectedValueOnce(new Error('upstream unreachable'));
const res = await request(app)
.post(`${IMMICH}/search`)
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(502);
expect(res.body.error).toBeDefined();
});
});
// ── Asset proxy ───────────────────────────────────────────────────────────────
describe('Immich asset proxy', () => {
it('IMMICH-050 — GET /assets/info returns asset metadata for own photo', 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-info-1', 'immich', { shared: false });
const res = await request(app)
.get(`${IMMICH}/assets/${trip.id}/asset-info-1/${user.id}/info`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: 'asset-info-1', city: 'Paris', country: 'France' });
});
it('IMMICH-051 — GET /assets/info with invalid assetId (special chars) returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// ID contains characters outside [a-zA-Z0-9_-] → fails isValidAssetId()
const invalidId = 'asset!@#$%';
const res = await request(app)
.get(`${IMMICH}/assets/${trip.id}/${encodeURIComponent(invalidId)}/${user.id}/info`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
});
it('IMMICH-052 — GET /assets/info by non-owner of unshared photo returns 403', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
setImmichCredentials(testDb, owner.id, 'https://immich.example.com', 'test-api-key');
// private photo — shared = false
addTripPhoto(testDb, trip.id, owner.id, 'asset-private', 'immich', { shared: false });
const res = await request(app)
.get(`${IMMICH}/assets/${trip.id}/asset-private/${owner.id}/info`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(403);
});
it('IMMICH-053 — GET /assets/info by trip member for shared photo returns 200', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
setImmichCredentials(testDb, owner.id, 'https://immich.example.com', 'test-api-key');
// shared photo
addTripPhoto(testDb, trip.id, owner.id, 'asset-shared', 'immich', { shared: true });
const res = await request(app)
.get(`${IMMICH}/assets/${trip.id}/asset-shared/${owner.id}/info`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(200);
});
it('IMMICH-054 — GET /assets/thumbnail for own photo streams image data', 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-thumb', 'immich', { shared: false });
const res = await request(app)
.get(`${IMMICH}/assets/${trip.id}/asset-thumb/${user.id}/thumbnail`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('image/webp');
expect(res.body).toBeDefined();
});
it('IMMICH-055 — GET /assets/thumbnail for other\'s unshared photo returns 403', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
addTripPhoto(testDb, trip.id, owner.id, 'asset-noshare', 'immich', { shared: false });
const res = await request(app)
.get(`${IMMICH}/assets/${trip.id}/asset-noshare/${owner.id}/thumbnail`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(403);
});
it('IMMICH-056 — GET /assets/original for shared photo streams image data', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
setImmichCredentials(testDb, owner.id, 'https://immich.example.com', 'test-api-key');
addTripPhoto(testDb, trip.id, owner.id, 'asset-orig', 'immich', { shared: true });
const res = await request(app)
.get(`${IMMICH}/assets/${trip.id}/asset-orig/${owner.id}/original`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('image/');
});
it('IMMICH-057 — GET /assets/info where trip does not exist returns 403', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
// Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily)
testDb.exec('PRAGMA foreign_keys = OFF');
testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-notrip', owner.id);
const tkpNotrip = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-notrip', owner.id) as any;
testDb.prepare(
'INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, ?)'
).run(9999, owner.id, tkpNotrip.id, 1);
testDb.exec('PRAGMA foreign_keys = ON');
const res = await request(app)
.get(`${IMMICH}/assets/9999/asset-notrip/${owner.id}/info`)
.set('Cookie', authCookie(member.id));
// canAccessUserPhoto: shared photo found, but canAccessTrip(9999) → null → false → 403
expect(res.status).toBe(403);
});
it('IMMICH-058 — GET /assets/info when upstream returns error propagates status', 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-upstream-err', 'immich', { shared: false });
vi.mocked(safeFetch).mockResolvedValueOnce({
ok: false, status: 503,
headers: { get: () => null } as any,
json: async () => ({}),
} as any);
const res = await request(app)
.get(`${IMMICH}/assets/${trip.id}/asset-upstream-err/${user.id}/info`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(503);
expect(res.body.error).toBeDefined();
});
});
// ── Albums ────────────────────────────────────────────────────────────────────
describe('Immich albums', () => {
it('IMMICH-060 — GET /albums when not configured returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get(`${IMMICH}/albums`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
});
it('IMMICH-061 — GET /albums returns album list', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
const res = await request(app)
.get(`${IMMICH}/albums`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.albums)).toBe(true);
expect(res.body.albums[0]).toMatchObject({ id: 'album-uuid-1', albumName: 'Vacation 2024' });
});
});
// ── Album photos (#1492) ──────────────────────────────────────────────────────
describe('Immich album photos', () => {
it('IMMICH-062 — GET /albums/:id/photos returns photos even though the album detail body has no `assets` (Immich v3)', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
const res = await request(app)
.get(`${IMMICH}/albums/album-uuid-1/photos`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.assets).toHaveLength(2);
// Newest first: asset-sync-2 was taken on the 2nd, asset-sync-1 on the 1st.
expect(res.body.assets.map((a: any) => a.id)).toEqual(['asset-sync-2', 'asset-sync-1']);
});
it('IMMICH-063 — GET /albums/:id/photos filters hidden assets (both visibility and legacy isVisible markers)', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
const res = await request(app)
.get(`${IMMICH}/albums/album-uuid-1/photos`)
.set('Cookie', authCookie(user.id));
const ids = res.body.assets.map((a: any) => a.id);
expect(ids).not.toContain('asset-hidden');
expect(ids).not.toContain('asset-legacy-hidden');
});
it('IMMICH-064 — GET /albums/:id/photos maps exif and media type', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
const res = await request(app)
.get(`${IMMICH}/albums/album-uuid-1/photos`)
.set('Cookie', authCookie(user.id));
// Keyed by id, not by position: the response is sorted by capture time now,
// so an index would pin the ordering here as a side effect.
const byId = Object.fromEntries(res.body.assets.map((a: any) => [a.id, a]));
expect(byId['asset-sync-1']).toMatchObject({
id: 'asset-sync-1',
takenAt: '2024-06-01T10:00:00.000Z',
city: 'Paris',
country: 'France',
mediaType: 'image',
});
expect(byId['asset-sync-2'].mediaType).toBe('video');
});
// #1614 — the album mapping used to drop lat/lng even though the search path
// kept them, so a photo picked from an album could never reach a map.
it('IMMICH-064b — album photos carry their capture coordinates, and tolerate their absence', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
const res = await request(app)
.get(`${IMMICH}/albums/album-uuid-1/photos`)
.set('Cookie', authCookie(user.id));
const byId = Object.fromEntries(res.body.assets.map((a: any) => [a.id, a]));
expect(byId['asset-sync-1']).toMatchObject({ lat: 48.8584, lng: 2.2945 });
// asset-sync-2 has no coordinates; it must come back null, not undefined
// or a half pair.
expect(byId['asset-sync-2'].lat).toBeNull();
expect(byId['asset-sync-2'].lng).toBeNull();
});
it('IMMICH-065 — album photos are fetched via search/metadata with albumIds and withExif', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
await request(app)
.get(`${IMMICH}/albums/album-uuid-1/photos`)
.set('Cookie', authCookie(user.id));
expect(immichState.searchCalls).toHaveLength(1);
// withExif is required: without it Immich omits exifInfo entirely and
// city/country would silently become null for every photo.
expect(immichState.searchCalls[0]).toMatchObject({
albumIds: ['album-uuid-1'],
withExif: true,
// The album path asks for the order too; without this it could fall out
// again unnoticed, because the local sort would still hide it here.
order: 'desc',
page: 1,
});
});
it('IMMICH-066 — GET /albums/:id/photos pages through albums larger than one page', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
// Page 1 is exactly `size` long, so the service must ask for page 2.
const pageOne = Array.from({ length: 1000 }, (_, i) => ({
id: `bulk-${i}`, type: 'IMAGE', fileCreatedAt: '2024-06-01T10:00:00.000Z',
}));
const pageTwo = [{ id: 'tail-asset', type: 'IMAGE', fileCreatedAt: '2024-06-02T10:00:00.000Z' }];
immichState.albumAssetPages = [pageOne, pageTwo];
const res = await request(app)
.get(`${IMMICH}/albums/album-uuid-1/photos`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.assets).toHaveLength(1001);
// tail-asset is the only one from page two and the newest of the 1001, so
// the chronological sort puts it first. Its presence is what proves page
// two was fetched at all.
expect(res.body.assets[0].id).toBe('tail-asset');
expect(immichState.searchCalls.map((c) => c.page)).toEqual([1, 2]);
});
// Immich v2 still embeds `assets` in the album detail body. It must be used as-is:
// v2's searchMetadata unconditionally scopes to `[self, ...partners]`, so an
// albumIds search there returns nothing for an album shared by a non-partner.
it('IMMICH-069a — on Immich v2 album photos come from the album detail body, with no search call', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
immichState.albumDetailHasAssets = true;
const res = await request(app)
.get(`${IMMICH}/albums/album-uuid-1/photos`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.assets.map((a: any) => a.id)).toEqual(['asset-sync-2', 'asset-sync-1']);
expect(immichState.searchCalls).toHaveLength(0);
});
it('IMMICH-069b — on Immich v2 an empty album stays empty and does not fall back to search', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
immichState.albumDetailHasAssets = true;
immichState.albumAssets = [];
const res = await request(app)
.get(`${IMMICH}/albums/album-uuid-1/photos`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.assets).toEqual([]);
expect(immichState.searchCalls).toHaveLength(0);
});
it('IMMICH-069c — on Immich v2 sync reads the album detail body', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024');
immichState.albumDetailHasAssets = true;
const res = await request(app)
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.added).toBe(1);
expect(immichState.searchCalls).toHaveLength(0);
});
it('IMMICH-067 — GET /albums/:id/photos when not configured returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get(`${IMMICH}/albums/album-uuid-1/photos`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
});
it('IMMICH-068 — GET /albums/:id/photos when Immich is unreachable returns 502', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
vi.mocked(safeFetch).mockRejectedValueOnce(new Error('network failure'));
const res = await request(app)
.get(`${IMMICH}/albums/album-uuid-1/photos`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(502);
});
});
// ── Auth checks ───────────────────────────────────────────────────────────────
describe('Immich auth checks', () => {
it('IMMICH-070 — GET /status without auth returns 401', async () => {
expect((await request(app).get(`${IMMICH}/status`)).status).toBe(401);
});
it('IMMICH-070 — POST /test without auth returns 401', async () => {
expect((await request(app).post(`${IMMICH}/test`)).status).toBe(401);
});
it('IMMICH-070 — GET /browse without auth returns 401', async () => {
expect((await request(app).get(`${IMMICH}/browse`)).status).toBe(401);
});
it('IMMICH-070 — POST /search without auth returns 401', async () => {
expect((await request(app).post(`${IMMICH}/search`)).status).toBe(401);
});
it('IMMICH-070 — GET /albums without auth returns 401', async () => {
expect((await request(app).get(`${IMMICH}/albums`)).status).toBe(401);
});
it('IMMICH-070 — GET /assets/info without auth returns 401', async () => {
expect((await request(app).get(`${IMMICH}/assets/1/asset-x/1/info`)).status).toBe(401);
});
it('IMMICH-070 — GET /assets/thumbnail without auth returns 401', async () => {
expect((await request(app).get(`${IMMICH}/assets/1/asset-x/1/thumbnail`)).status).toBe(401);
});
it('IMMICH-070 — GET /assets/original without auth returns 401', async () => {
expect((await request(app).get(`${IMMICH}/assets/1/asset-x/1/original`)).status).toBe(401);
});
});
// ── Album sync ────────────────────────────────────────────────────────────────
describe('Immich syncAlbumAssets', () => {
it('IMMICH-080 — POST sync happy path: trip owner with album link saves photos to DB', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024');
const res = await request(app)
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Only asset-sync-1 is an IMAGE; the video and the hidden motion parts are
// not synced. A zero here is the #1492 silent-failure mode.
expect(res.body.total).toBe(1);
expect(res.body.added).toBe(1);
// Verify photos were inserted into the DB
const photos = testDb.prepare(`
SELECT tp.*, tkp.provider FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.trip_id = ? AND tp.user_id = ?
`).all(trip.id, user.id) as any[];
expect(photos.length).toBeGreaterThan(0);
expect(photos[0].provider).toBe('immich');
});
it('IMMICH-087 — POST sync does not persist hidden assets (#1474)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024');
// A hidden IMAGE slips past the `type === 'IMAGE'` filter. Persisting it
// yields a permanently broken tile: nothing re-checks visibility on the
// render path, and Immich has no thumbnail to serve.
immichState.albumAssets = [
{ id: 'visible-still', type: 'IMAGE', visibility: 'timeline', fileCreatedAt: '2024-06-01T10:00:00.000Z' },
{ id: 'hidden-image', type: 'IMAGE', visibility: 'hidden', fileCreatedAt: '2024-06-02T10:00:00.000Z' },
{ id: 'legacy-hidden-image', type: 'IMAGE', isVisible: false, fileCreatedAt: '2024-06-03T10:00:00.000Z' },
];
const res = await request(app)
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.total).toBe(1);
const rows = testDb.prepare(`
SELECT tkp.asset_id FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.trip_id = ?
`).all(trip.id) as any[];
expect(rows.map((r) => r.asset_id)).toEqual(['visible-still']);
});
it('IMMICH-086 — POST sync fetches album contents via search/metadata with albumIds', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024');
await request(app)
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(user.id));
expect(immichState.searchCalls[0]).toMatchObject({ albumIds: ['album-uuid-1'] });
});
it('IMMICH-081 — POST sync when user is not a trip member returns 404', async () => {
const { user: owner } = createUser(testDb);
const { user: outsider } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
setImmichCredentials(testDb, owner.id, 'https://immich.example.com', 'test-api-key');
const link = addAlbumLink(testDb, trip.id, owner.id, 'immich', 'album-uuid-1', 'Vacation 2024');
// outsider is not a trip member — getAlbumIdFromLink checks canAccessTrip
const res = await request(app)
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(outsider.id));
expect(res.status).toBe(404);
});
it('IMMICH-082 — POST sync when Immich is not configured returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// No Immich credentials set — but still need a valid album link owned by user
const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024');
const res = await request(app)
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('IMMICH-083 — POST sync when safeFetch throws returns 502', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024');
vi.mocked(safeFetch).mockRejectedValueOnce(new Error('network failure during sync'));
const res = await request(app)
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(502);
expect(res.body.error).toBeDefined();
});
it('IMMICH-084 — POST sync when album link does not belong to requesting user returns 404', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
setImmichCredentials(testDb, member.id, 'https://immich.example.com', 'test-api-key');
// Album link is owned by owner, not member
const link = addAlbumLink(testDb, trip.id, owner.id, 'immich', 'album-uuid-1', 'Vacation 2024');
// member is a trip member but the album link belongs to owner — getAlbumIdFromLink checks user_id
const res = await request(app)
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(404);
});
it('IMMICH-085 — POST sync without auth returns 401', async () => {
expect((await request(app).post(`${IMMICH}/trips/1/album-links/1/sync`)).status).toBe(401);
});
});
// ── searchPhotos pagination safety ────────────────────────────────────────────
describe('Immich searchPhotos pagination pass-through', () => {
it('IMMICH-090 — POST /search proxies client page param and returns hasMore', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
// Return a full page so hasMore=true (items.length >= size)
const fullPageResponse = {
ok: true, status: 200,
headers: { get: () => null },
json: () => Promise.resolve({
assets: {
items: Array.from({ length: 50 }, (_, i) => ({
id: `asset-p2-${i}`,
fileCreatedAt: '2024-06-01T10:00:00.000Z',
exifInfo: { city: 'Berlin', country: 'Germany' },
})),
},
}),
body: null,
} as any;
vi.mocked(safeFetch).mockClear();
vi.mocked(safeFetch).mockResolvedValue(fullPageResponse);
const res = await request(app)
.post(`${IMMICH}/search`)
.set('Cookie', authCookie(user.id))
.send({ page: 2, size: 50 });
expect(res.status).toBe(200);
expect(Array.isArray(res.body.assets)).toBe(true);
// Single page returned — not 20× aggregation
expect(res.body.assets.length).toBe(50);
expect(res.body.hasMore).toBe(true);
// Immich was called exactly once
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(1);
// page=2 was forwarded to Immich
const callBody = JSON.parse(vi.mocked(safeFetch).mock.calls[0][1]!.body as string);
expect(callBody.page).toBe(2);
});
it('IMMICH-091 — POST /search returns hasMore=false on last page', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
// Partial page → hasMore=false
const partialPageResponse = {
ok: true, status: 200,
headers: { get: () => null },
json: () => Promise.resolve({
assets: {
items: Array.from({ length: 3 }, (_, i) => ({
id: `asset-last-${i}`,
fileCreatedAt: '2024-06-01T10:00:00.000Z',
exifInfo: { city: 'Rome', country: 'Italy' },
})),
},
}),
body: null,
} as any;
vi.mocked(safeFetch).mockResolvedValue(partialPageResponse);
const res = await request(app)
.post(`${IMMICH}/search`)
.set('Cookie', authCookie(user.id))
.send({ page: 5, size: 50 });
expect(res.status).toBe(200);
expect(res.body.assets.length).toBe(3);
expect(res.body.hasMore).toBe(false);
});
it('IMMICH-093 — POST /search requests timeline visibility so Immich v3 never returns hidden assets (#1474)', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
// A sibling test installs a permanent mockResolvedValue on safeFetch, so
// assert on the spy's recorded call rather than immichState.searchCalls.
vi.mocked(safeFetch).mockClear();
vi.mocked(safeFetch).mockResolvedValue({
ok: true, status: 200,
headers: { get: () => null },
json: () => Promise.resolve({ assets: { items: [] } }),
body: null,
} as any);
await request(app)
.post(`${IMMICH}/search`)
.set('Cookie', authCookie(user.id))
.send({ from: '2024-06-01', to: '2024-06-14' });
// Immich v2 hard-defaulted metadata search to `timeline` visibility; v3
// defaults to "any except locked", which is what started surfacing Live
// Photo motion parts. Asking for `timeline` explicitly restores v2
// semantics on both, so hidden assets never cross the wire.
const callBody = JSON.parse(vi.mocked(safeFetch).mock.calls[0][1]!.body as string);
expect(callBody.visibility).toBe('timeline');
});
it('IMMICH-092 — POST /search filters hidden Live Photo motion assets (#1474)', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
// A Live Photo pair (visible still + hidden motion video) plus a legacy
// hidden asset — only the still should survive, but hasMore reflects the
// raw page count (4 >= size 4).
const mixedResponse = {
ok: true, status: 200,
headers: { get: () => null },
json: () => Promise.resolve({
assets: {
items: [
{ id: 'still-1', type: 'IMAGE', visibility: 'timeline', fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Kyoto', country: 'Japan' }, livePhotoVideoId: 'motion-1' },
{ id: 'motion-1', type: 'VIDEO', visibility: 'hidden', fileCreatedAt: '2024-06-01T10:00:00.000Z' },
{ id: 'legacy-hidden', type: 'VIDEO', isVisible: false, fileCreatedAt: '2024-06-01T10:00:00.000Z' },
{ id: 'video-visible', type: 'VIDEO', visibility: 'timeline', fileCreatedAt: '2024-06-01T10:00:00.000Z' },
],
},
}),
body: null,
} as any;
vi.mocked(safeFetch).mockResolvedValue(mixedResponse);
const res = await request(app)
.post(`${IMMICH}/search`)
.set('Cookie', authCookie(user.id))
.send({ page: 1, size: 4 });
expect(res.status).toBe(200);
// motion-1 (hidden) and legacy-hidden (isVisible:false) are dropped.
expect(res.body.assets.map((a: any) => a.id)).toEqual(['still-1', 'video-visible']);
// Ordinary visible video survives, tagged as video.
expect(res.body.assets.find((a: any) => a.id === 'video-visible').mediaType).toBe('video');
// hasMore stays on raw page length so pagination advances past a filtered page.
expect(res.body.hasMore).toBe(true);
});
});
// ── saveImmichSettings clearing credentials ───────────────────────────────────
describe('Immich saveImmichSettings clearing URL', () => {
it('IMMICH-095 — PUT /settings with no URL clears immich_url but preserves (updates) api key', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'old-key');
// Send without immich_url to trigger the else branch (clear URL path)
const res = await request(app)
.put(`${IMMICH}/settings`)
.set('Cookie', authCookie(user.id))
.send({ immich_api_key: 'new-key' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const row = testDb.prepare('SELECT immich_url FROM users WHERE id = ?').get(user.id) as any;
expect(row.immich_url).toBeNull();
});
it('IMMICH-096 — PUT /settings with empty string URL clears immich_url', async () => {
const { user } = createUser(testDb);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'old-key');
const res = await request(app)
.put(`${IMMICH}/settings`)
.set('Cookie', authCookie(user.id))
.send({ immich_url: '', immich_api_key: 'old-key' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const row = testDb.prepare('SELECT immich_url FROM users WHERE id = ?').get(user.id) as any;
expect(row.immich_url).toBeNull();
});
});
// ── testConnection canonical URL detection ────────────────────────────────────
describe('Immich testConnection canonical URL detection', () => {
it('IMMICH-100 — POST /test with http URL that gets upgraded to https returns canonicalUrl', async () => {
const { user } = createUser(testDb);
// Mock safeFetch so the response.url reflects https upgrade
vi.mocked(safeFetch).mockResolvedValueOnce({
ok: true,
status: 200,
url: 'https://immich.example.com/api/users/me',
headers: { get: (h: string) => h === 'content-type' ? 'application/json' : null } as any,
json: async () => ({ name: 'Redirect User', email: 'redirect@immich.local' }),
body: null,
} as any);
const res = await request(app)
.post(`${IMMICH}/test`)
.set('Cookie', authCookie(user.id))
.send({ immich_url: 'http://immich.example.com', immich_api_key: 'valid-key' });
expect(res.status).toBe(200);
expect(res.body.connected).toBe(true);
expect(res.body.canonicalUrl).toBe('https://immich.example.com');
});
it('IMMICH-101 — POST /test with https URL that stays https does not return canonicalUrl', async () => {
const { user } = createUser(testDb);
// The default mock returns a response without .url property — no upgrade
const res = await request(app)
.post(`${IMMICH}/test`)
.set('Cookie', authCookie(user.id))
.send({ immich_url: 'https://immich.example.com', immich_api_key: 'valid-key' });
expect(res.status).toBe(200);
expect(res.body.connected).toBe(true);
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 for the same server, a new server or 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);
// A different server is a different trust decision: without the switch it starts off.
await put({ immich_url: 'https://photos.example.com', immich_api_key: 'k' });
expect(allowInsecureTls(user.id)).toBe(0);
await put({ immich_url: 'https://photos.example.com', immich_api_key: 'k', allow_insecure_tls: true });
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);
});
});