/** * 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'; // ── Hoisted DB mock ────────────────────────────────────────────────────────── const { testDb, dbMock } = 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'); 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 }; }); vi.mock('../../src/db/database', () => dbMock); vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); // ── SSRF guard mock — routes all Immich API calls to fake responses ─────────── vi.mock('../../src/utils/ssrfGuard', async () => { const actual = await vi.importActual('../../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 — search if (u.includes('/api/search/metadata')) { 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' } }, ], }, }), 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 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 (for sync) if (/\/api\/albums\//.test(u)) { return Promise.resolve({ ok: true, status: 200, headers: { get: () => null }, json: () => Promise.resolve({ assets: [{ id: 'asset-sync-1', type: 'IMAGE' }] }), 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 { createApp } from '../../src/app'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { resetTestDb } from '../helpers/test-db'; import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink, setImmichCredentials } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; import { safeFetch } from '../../src/utils/ssrfGuard'; const app: Application = createApp(); const IMMICH = '/api/integrations/memories/immich'; beforeAll(() => { createTables(testDb); runMigrations(testDb); }); beforeEach(() => { resetTestDb(testDb); loginAttempts.clear(); mfaAttempts.clear(); }); afterAll(() => 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', 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({}); 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' }); }); 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/jpeg'); }); 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 INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' ).run(9999, owner.id, 'asset-notrip', 'immich', 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' }); }); }); // ── 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); }); });