Files
parenthese/backend/test/immich.test.ts
T
Askrosm fb791b6752 fix: address Immich import review feedback
Use Immich previews for photos, enforce the image download cap while streaming,
reuse design tokens and remove unused translations. Add regression coverage.

Signed-off-by: Askrosm <155456680+Askrosm@users.noreply.github.com>
2026-10-20 22:05:42 +02:00

1217 lines
49 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.
import { randomUUID } from 'node:crypto'
import { mkdtemp, mkdir, rm, writeFile, stat, readdir } from 'node:fs/promises'
import path from 'node:path'
import os from 'node:os'
import sharp from 'sharp'
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
user: {
count: vi.fn(),
},
person: {
findFirst: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
treeMembership: {
findUnique: vi.fn(),
},
treeImmichConfig: {
findUnique: vi.fn(),
},
mediaItem: {
aggregate: vi.fn(),
},
auditLog: {
create: vi.fn(),
},
$transaction: vi.fn(),
$disconnect: vi.fn(),
},
}))
vi.mock('../src/lib/prisma.js', () => ({
prisma: prismaMock,
}))
let createApp: () => FastifyInstance
let storageRoot: string
let jpegBytes: Buffer
let treeApiKeyEnc = ''
const immichPersonId = '123e4567-e89b-42d3-a456-426614174000'
const otherImmichId = '123e4567-e89b-42d3-a456-426614174111'
const fetchMock = vi.fn()
async function jsonResponse(payload: unknown, status = 200): Promise<Response> {
return new Response(JSON.stringify(payload), { status, headers: { 'content-type': 'application/json' } })
}
// Association : deux appels Immich possibles, la personne (JSON) puis sa vignette (image).
// Le 404 de /api/people/{id} dit que la personne n'existe pas ; celui de /thumbnail seul
// ne dit rien (personne absente ou vignette manquante).
function mockImmichPerson(options: { thumbnailPath?: string; thumbnailStatus?: number } = {}): void {
const { thumbnailPath = '/thumb/a.jpg', thumbnailStatus = 200 } = options
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.endsWith('/thumbnail')) {
return new Response(new Uint8Array(jpegBytes), { status: thumbnailStatus })
}
return jsonResponse({ id: immichPersonId, name: 'Jean Dupont', thumbnailPath, isHidden: false, birthDate: null })
})
}
describe('Souvenirs Immich', () => {
it('combine le contexte et un visage sur la recherche intelligente Immich', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValue(await jsonResponse({ assets: { items: [], nextPage: null } }))
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets?q=repas%20au%20jardin&personId=${immichPersonId}&page=2`, headers: { authorization: `Bearer ${token}` } })
expect(response.statusCode).toBe(200)
expect(fetchMock.mock.calls[0][0]).toBe('http://immich.test/api/search/smart')
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ page: 2, size: 30, type: 'IMAGE', isTrashed: false, query: 'repas au jardin', personIds: [immichPersonId] })
await app.close()
})
it('filtre par visage sans contexte via les métadonnées', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValue(await jsonResponse({ assets: { items: [], nextPage: null } }))
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets?personId=${immichPersonId}&q=%20%20`, headers: { authorization: `Bearer ${token}` } })
expect(response.statusCode).toBe(200)
expect(fetchMock.mock.calls[0][0]).toBe('http://immich.test/api/search/metadata')
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ personIds: [immichPersonId] })
await app.close()
})
it('refuse un visage invalide et un contexte excessif avant tout appel distant', async () => {
const app = createApp()
const token = await createUserToken(app)
for (const query of ['personId=invalid', `q=${'x'.repeat(501)}`]) {
expect((await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets?${query}`, headers: { authorization: `Bearer ${token}` } })).statusCode).toBe(400)
}
expect(fetchMock).not.toHaveBeenCalled()
await app.close()
})
it('charge un aperçu agrandi sans demander le fichier original', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValue(new Response(new Uint8Array(jpegBytes)))
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/preview`, headers: { authorization: `Bearer ${token}` } })
expect(response.statusCode).toBe(200)
expect(fetchMock.mock.calls[0][0]).toBe(`http://immich.test/api/assets/${immichPersonId}/thumbnail?size=preview`)
expect(response.headers['content-type']).toBe('image/jpeg')
await app.close()
})
it('réserve la photothèque aux administrateurs et ne contacte pas Immich en cas de refus', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.treeMembership.findUnique.mockResolvedValue(null)
const response = await app.inject({ method: 'GET', url: '/trees/tree-1/immich/assets', headers: { authorization: `Bearer ${token}` } })
expect(response.statusCode).toBe(403)
expect(fetchMock).not.toHaveBeenCalled()
await app.close()
})
it('pagine les vidéos sans exposer les métadonnées privées', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValue(await jsonResponse({ assets: { items: [{ id: immichPersonId, type: 'VIDEO', originalFileName: 'famille.mp4', originalPath: '/private/video.mp4' }], nextPage: '3' } }))
const response = await app.inject({ method: 'GET', url: '/trees/tree-1/immich/assets?page=2&type=VIDEO', headers: { authorization: `Bearer ${token}` } })
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ assets: [{ id: immichPersonId, type: 'VIDEO', name: 'famille.mp4' }], hasNextPage: true })
expect(fetchMock.mock.calls[0][0]).toBe('http://immich.test/api/search/metadata')
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ page: 2, type: 'VIDEO', size: 30 })
await app.close()
})
it('refuse les originaux trop gros avant de les lire', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValue(new Response('too large', { headers: { 'content-length': String(21 * 1024 * 1024) } }))
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } })
expect(response.statusCode).toBe(413)
await app.close()
})
it('sert une photo avec son type réel et refuse le HTML', async () => {
const app = createApp()
const token = await createUserToken(app)
const options = { method: 'GET' as const, url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } }
fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(jpegBytes)))
const response = await app.inject(options)
expect(response.statusCode).toBe(200)
expect(response.headers['content-type']).toBe('image/jpeg')
fetchMock.mockResolvedValueOnce(new Response('<html>unsafe</html>'))
expect((await app.inject(options)).statusCode).toBe(400)
await app.close()
})
it.each(['preview', 'original'])('coupe une image à 5 Mo en flux sur %s, même déclarée vidéo', async (variant) => {
const app = createApp()
const token = await createUserToken(app)
const cancel = vi.fn()
let sent = 0
const stream = new ReadableStream({
pull(controller) {
// La signature JPEG arrive en trois chunks, sans Content-Length.
if (sent < 3) controller.enqueue(new Uint8Array(jpegBytes.subarray(sent, ++sent)))
else { sent++; controller.enqueue(new Uint8Array(1024 * 1024)) }
}, cancel,
}, { highWaterMark: 0 })
fetchMock.mockResolvedValueOnce(new Response(stream, { headers: { 'content-type': 'video/mp4' } }))
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/${variant}`, headers: { authorization: `Bearer ${token}` } })
expect(response.statusCode).toBe(413)
expect(cancel).toHaveBeenCalledOnce()
expect(sent).toBe(8) // 3 octets + 5 Mo : aucun chunk supplémentaire lu.
await app.close()
})
it('refuse une image annoncée au-dessus de 5 Mo avant toute lecture', async () => {
const app = createApp()
const token = await createUserToken(app)
const pull = vi.fn()
const cancel = vi.fn()
fetchMock.mockResolvedValueOnce(new Response(new ReadableStream({ pull, cancel }, { highWaterMark: 0 }), {
headers: { 'content-type': 'image/jpeg', 'content-length': String(6 * 1024 * 1024) },
}))
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } })
expect(response.statusCode).toBe(413)
expect(pull).not.toHaveBeenCalled()
expect(cancel).toHaveBeenCalledOnce()
await app.close()
})
it('conserve la limite vidéo de 20 Mo au-delà de 5 Mo', async () => {
const app = createApp()
const token = await createUserToken(app)
const video = Buffer.alloc(6 * 1024 * 1024)
video.set(Buffer.from([0, 0, 0, 24, ...Buffer.from('ftypisom')]))
fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(video)))
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } })
expect(response.statusCode).toBe(200)
expect(response.rawPayload.equals(video)).toBe(true)
await app.close()
})
it('importe les octets d’une vidéo et coupe un flux qui dépasse la limite', async () => {
const app = createApp()
const token = await createUserToken(app)
const options = { method: 'GET' as const, url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } }
const video = Buffer.from([0, 0, 0, 24, ...Buffer.from('ftypisom'), 0, 0, 0, 0])
fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(video)))
const response = await app.inject(options)
expect(response.statusCode).toBe(200)
expect(response.headers['content-type']).toBe('video/mp4')
expect(response.rawPayload).toEqual(video)
const cancel = vi.fn()
fetchMock.mockResolvedValueOnce(new Response(new ReadableStream({
start(controller) { controller.enqueue(new Uint8Array(21 * 1024 * 1024)) }, cancel,
})))
expect((await app.inject(options)).statusCode).toBe(413)
expect(cancel).toHaveBeenCalled()
await app.close()
})
})
async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise<string> {
await app.ready()
return (app as any).jwt.sign(
{ kind: 'user', sub: userId, jti: randomUUID(), userId, email: 'user@example.com' },
{ expiresIn: '1h' },
)
}
async function absStoragePath(relativePath: string): Promise<string> {
return path.resolve(storageRoot, relativePath)
}
async function fsExists(absolutePath: string): Promise<boolean> {
try {
await stat(absolutePath)
return true
} catch {
return false
}
}
async function fsListDir(absolutePath: string): Promise<string[]> {
try {
return await readdir(absolutePath)
} catch {
return []
}
}
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.PORT = '4000'
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.JWT_EXPIRES_IN = '7d'
process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h'
process.env.CORS_ORIGIN = 'http://localhost:5173'
// Plus de variables d'environnement Immich : la connexion de l'arbre vit en base,
// un arbre sans ligne n'a jamais accès à l'instance d'un autre.
process.env.IMMICH_ENABLED = 'true'
process.env.IMMICH_API_URL = 'http://env-immich.example.org'
process.env.IMMICH_API_KEY = 'env-key'
// L'instance de l'arbre fait partie de la liste autorisée par l'opérateur.
process.env.IMMICH_ALLOWED_API_URLS = 'http://immich.test,Partage=http://immich.test/immich'
storageRoot = await mkdtemp(path.join(os.tmpdir(), 'parenthese-immich-'))
process.env.MEDIA_STORAGE_PATH = storageRoot
// Quota serré pour le tester : le disque est mesuré en vrai (taille du dossier de l'arbre).
process.env.MEDIA_QUOTA_PER_TREE_MB = '1'
jpegBytes = await sharp({ create: { width: 8, height: 8, channels: 3, background: '#aabbcc' } }).jpeg().toBuffer()
// Clé chiffrée avec le secret effectif de l'application : import dynamique pour que
// la configuration d'environnement ci-dessus soit déjà en place.
const secret = await import('../src/lib/immich-secret.js')
treeApiKeyEnc = secret.encryptImmichApiKey('test-key')
const module = await import('../src/app.js')
createApp = module.createApp
vi.stubGlobal('fetch', fetchMock)
})
afterAll(async () => {
await rm(storageRoot, { recursive: true, force: true })
vi.unstubAllGlobals()
})
beforeEach(async () => {
vi.clearAllMocks()
prismaMock.user.count.mockResolvedValue(1)
// La transaction de l'API prend un tableau d'opérations ; les autres tests du dépôt utilisent la forme callback.
prismaMock.$transaction.mockImplementation(async (input: unknown) => {
if (Array.isArray(input)) {
return Promise.all(input)
}
return (input as (tx: typeof prismaMock) => Promise<unknown>)(prismaMock)
})
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'owner',
tree: { deletedAt: null },
})
// Connexion Immich de l'arbre : résolue en base, clé chiffrée comme en production.
prismaMock.treeImmichConfig.findUnique.mockResolvedValue({ apiUrl: 'http://immich.test', apiKeyEnc: treeApiKeyEnc })
prismaMock.person.findFirst.mockResolvedValue(null)
prismaMock.person.update.mockResolvedValue({ id: 'person-1' })
prismaMock.person.updateMany.mockResolvedValue({ count: 1 })
prismaMock.auditLog.create.mockResolvedValue({ id: 'audit-1' })
prismaMock.mediaItem.aggregate.mockResolvedValue({ _sum: { sizeBytes: 0 } })
fetchMock.mockReset()
fetchMock.mockResolvedValue(await jsonResponse({ res: 'pong' }))
})
describe('Immich integration: status and people', () => {
it('talks to the tree\'s own Immich instance, never to the server environment', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValue(await jsonResponse({ res: 'pong' }))
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/status',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ available: true, reason: null })
// La connexion vient de la base (http://immich.test), pas des variables d'environnement
// (http://env-immich.example.org), et la clé passe par l'en-tête, jamais dans l'URL.
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(String(fetchMock.mock.calls[0][0])).toBe('http://immich.test/api/server/ping')
expect((fetchMock.mock.calls[0][1] as RequestInit).headers).toMatchObject({ 'x-api-key': 'test-key' })
// La clé est lue depuis l'arbre demandé, pas depuis un autre.
expect(prismaMock.treeImmichConfig.findUnique).toHaveBeenCalledWith({
where: { treeId: 'tree-1' },
select: { apiUrl: true, apiKeyEnc: true },
})
await app.close()
})
it('reports Immich available when ping answers pong', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValue(await jsonResponse({ res: 'pong' }))
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/status',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ available: true, reason: null })
await app.close()
})
it('reports Immich unreachable when ping fails', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockRejectedValue(new Error('network down'))
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/status',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ available: false, reason: 'unreachable' })
await app.close()
})
it('rejects unauthenticated and non-admin requests on status', async () => {
const app = createApp()
const anonymous = await app.inject({ method: 'GET', url: '/trees/tree-1/immich/status' })
expect(anonymous.statusCode).toBe(401)
const token = await createUserToken(app)
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'member',
tree: { deletedAt: null },
})
const member = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/status',
headers: { authorization: `Bearer ${token}` },
})
expect(member.statusCode).toBe(403)
await app.close()
})
it('lists people across Immich pages', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/api/people?')) {
const page = url.includes('page=2') ? 2 : 1
if (page === 1) {
return jsonResponse({
people: [{ id: immichPersonId, name: 'Jean Dupont', thumbnailPath: '/thumb/a.jpg', isHidden: false, birthDate: null }],
total: 2,
hasNextPage: true,
hidden: 0,
})
}
return jsonResponse({
people: [{ id: otherImmichId, name: 'Marie Martin', thumbnailPath: '', isHidden: false, birthDate: null }],
total: 2,
hasNextPage: false,
hidden: 0,
})
}
return jsonResponse({ res: 'pong' })
})
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/people',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json().people).toEqual([
{ id: immichPersonId, name: 'Jean Dupont', hasThumbnail: true },
{ id: otherImmichId, name: 'Marie Martin', hasThumbnail: false },
])
await app.close()
})
it('maps an Immich auth failure to 502 and a malformed response to 503', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValue(new Response(JSON.stringify({ message: 'Invalid API key' }), { status: 401 }))
const authFailure = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/people',
headers: { authorization: `Bearer ${token}` },
})
expect(authFailure.statusCode).toBe(502)
expect(authFailure.json()).toEqual({ error: 'immich_auth_failed' })
fetchMock.mockResolvedValue(await jsonResponse({ total: 0, hasNextPage: false }))
const malformed = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/people',
headers: { authorization: `Bearer ${token}` },
})
expect(malformed.statusCode).toBe(503)
expect(malformed.json()).toEqual({ error: 'immich_unreachable' })
await app.close()
})
})
describe('Immich integration: thumbnail proxy', () => {
it('proxies a thumbnail with its sniffed content type', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValue(new Response(new Uint8Array(jpegBytes), { status: 200 }))
const response = await app.inject({
method: 'GET',
url: `/trees/tree-1/immich/thumbnail/${immichPersonId}`,
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.headers['content-type']).toContain('image/jpeg')
expect(Buffer.from(response.rawPayload).equals(jpegBytes)).toBe(true)
await app.close()
})
it('returns 404 when the thumbnail is missing and 400 for a malformed identifier', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValue(new Response('', { status: 404 }))
const missing = await app.inject({
method: 'GET',
url: `/trees/tree-1/immich/thumbnail/${immichPersonId}`,
headers: { authorization: `Bearer ${token}` },
})
expect(missing.statusCode).toBe(404)
expect(missing.json()).toEqual({ error: 'immich_person_not_found' })
const invalid = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/thumbnail/not-a-uuid',
headers: { authorization: `Bearer ${token}` },
})
expect(invalid.statusCode).toBe(400)
await app.close()
})
})
describe('Immich integration: assign', () => {
it('downloads the thumbnail, replaces the avatar and audits the change', async () => {
const app = createApp()
const token = await createUserToken(app)
// Portrait manuel en place : l'administrateur a confirmé son remplacement, il doit
// être supprimé une fois la base à jour.
const oldAvatarRelative = 'tree-1/person-1/avatar-old.jpg'
await mkdir(path.dirname(await absStoragePath(oldAvatarRelative)), { recursive: true })
await writeFile(await absStoragePath(oldAvatarRelative), 'old avatar')
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: oldAvatarRelative })
mockImmichPerson()
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1', immichPersonId, replaceAvatar: true },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ ok: true, avatarUpdated: true, immichPersonId })
const updateData = prismaMock.person.update.mock.calls[0][0].data
expect(updateData.immichPersonId).toBe(immichPersonId)
expect(updateData.avatarMimeType).toBe('image/jpeg')
expect(typeof updateData.avatarPath).toBe('string')
// Le nouveau portrait existe sur le disque, l'ancien a été retiré.
const newAvatar = await absStoragePath(updateData.avatarPath)
await expect(fsExists(newAvatar)).resolves.toBe(true)
await expect(fsExists(await absStoragePath(oldAvatarRelative))).resolves.toBe(false)
expect(prismaMock.auditLog.create).toHaveBeenCalledWith({
data: {
treeId: 'tree-1',
actorType: 'user',
actorId: 'user-1',
action: 'immich_assign',
entityType: 'person',
entityId: 'person-1',
payloadJson: { immichPersonId, avatarUpdated: true, avatarPath: updateData.avatarPath },
},
})
await app.close()
})
it('asks before replacing an existing portrait, whatever its origin', async () => {
const app = createApp()
const token = await createUserToken(app)
mockImmichPerson()
// Portrait manuel en place : l'association ne le remplace pas sans accord explicite.
const manualAvatarRelative = 'tree-1/person-confirm/avatar-manuel.jpg'
await mkdir(path.dirname(await absStoragePath(manualAvatarRelative)), { recursive: true })
await writeFile(await absStoragePath(manualAvatarRelative), 'portrait manuel')
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-confirm', avatarPath: manualAvatarRelative })
const manual = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-confirm', immichPersonId },
})
expect(manual.statusCode).toBe(409)
expect(manual.json()).toEqual({ error: 'avatar_exists' })
// Même garde pour un portrait venu d'une association Immich précédente.
prismaMock.person.findFirst.mockResolvedValue({
id: 'person-confirm',
avatarPath: `tree-1/person-confirm/immich-${randomUUID()}.jpg`,
immichPersonId: otherImmichId,
})
const fromImmich = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-confirm', immichPersonId },
})
expect(fromImmich.statusCode).toBe(409)
expect(fromImmich.json()).toEqual({ error: 'avatar_exists' })
// Base, journal d'audit et fichiers : rien n'a bougé.
expect(prismaMock.person.update).not.toHaveBeenCalled()
expect(prismaMock.auditLog.create).not.toHaveBeenCalled()
expect(prismaMock.$transaction).not.toHaveBeenCalled()
await expect(fsExists(await absStoragePath(manualAvatarRelative))).resolves.toBe(true)
const files = await fsListDir(path.join(storageRoot, 'tree-1', 'person-confirm'))
expect(files.filter((name) => name.startsWith('immich-'))).toEqual([])
await app.close()
})
it('assigns without touching the avatar when Immich has no thumbnail', async () => {
const app = createApp()
const token = await createUserToken(app)
// Portrait manuel conservé : sans vignette Immich, l'association ne le remplace pas,
// aucune confirmation n'est donc nécessaire.
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: 'tree-1/person-1/avatar-keep.jpg' })
mockImmichPerson({ thumbnailPath: '' })
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1', immichPersonId },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ ok: true, avatarUpdated: false, immichPersonId })
// Sans vignette annoncée, l'appel de vignette n'a même pas lieu.
expect(fetchMock).toHaveBeenCalledTimes(1)
const updateData = prismaMock.person.update.mock.calls[0][0].data
expect(updateData.immichPersonId).toBe(immichPersonId)
expect(updateData).not.toHaveProperty('avatarPath')
expect(updateData).not.toHaveProperty('avatarMimeType')
await app.close()
})
it('still assigns without the avatar when the thumbnail vanishes after the person was found', async () => {
const app = createApp()
const token = await createUserToken(app)
// Immich annonce une vignette qui répond pourtant 404 : la personne existe,
// l'association se fait sans portrait.
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: null })
mockImmichPerson({ thumbnailStatus: 404 })
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1', immichPersonId },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ ok: true, avatarUpdated: false, immichPersonId })
const updateData = prismaMock.person.update.mock.calls[0][0].data
expect(updateData.immichPersonId).toBe(immichPersonId)
expect(updateData).not.toHaveProperty('avatarPath')
expect(updateData).not.toHaveProperty('avatarMimeType')
await app.close()
})
it('refuses the assignment when the Immich person does not exist', async () => {
const app = createApp()
const token = await createUserToken(app)
// Portrait en place : un refus ne doit rien changer.
// Dossier dédié : les autres tests laissent leurs portraits dans tree-1/person-1.
const avatarRelative = 'tree-1/person-missing/avatar-keep.jpg'
await mkdir(path.dirname(await absStoragePath(avatarRelative)), { recursive: true })
await writeFile(await absStoragePath(avatarRelative), 'existing avatar')
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-missing', avatarPath: avatarRelative })
// GET /api/people/{id} : la personne n'existe pas. Un 404 sur la vignette ne suffirait
// pas à le distinguer d'une simple vignette manquante.
fetchMock.mockResolvedValue(new Response('', { status: 404 }))
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-missing', immichPersonId },
})
expect(response.statusCode).toBe(404)
expect(response.json()).toEqual({ error: 'immich_person_not_found' })
// Base, journal d'audit et fichiers : rien n'a bougé.
expect(prismaMock.person.update).not.toHaveBeenCalled()
expect(prismaMock.auditLog.create).not.toHaveBeenCalled()
expect(prismaMock.$transaction).not.toHaveBeenCalled()
await expect(fsExists(await absStoragePath(avatarRelative))).resolves.toBe(true)
const files = await fsListDir(path.join(storageRoot, 'tree-1', 'person-missing'))
expect(files.filter((name) => name.startsWith('immich-'))).toEqual([])
await app.close()
})
it('maps an Immich auth failure during assignment to 502 without touching the database', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: null })
fetchMock.mockResolvedValue(new Response(JSON.stringify({ message: 'Invalid API key' }), { status: 401 }))
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1', immichPersonId },
})
// Un refus d'authentification n'est jamais pris pour une personne manquante.
expect(response.statusCode).toBe(502)
expect(response.json()).toEqual({ error: 'immich_auth_failed' })
expect(prismaMock.person.update).not.toHaveBeenCalled()
expect(prismaMock.auditLog.create).not.toHaveBeenCalled()
await app.close()
})
it('rejects invalid identifiers, unauthenticated, non-admin and unknown person', async () => {
const app = createApp()
const token = await createUserToken(app)
const invalidId = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1', immichPersonId: '../etc/passwd' },
})
expect(invalidId.statusCode).toBe(400)
expect(invalidId.json()).toEqual({ error: 'invalid_immich_person_id' })
const anonymous = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
payload: { personId: 'person-1', immichPersonId },
})
expect(anonymous.statusCode).toBe(401)
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'member',
tree: { deletedAt: null },
})
const member = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1', immichPersonId },
})
expect(member.statusCode).toBe(403)
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'owner',
tree: { deletedAt: null },
})
prismaMock.person.findFirst.mockResolvedValue(null)
const unknown = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-x', immichPersonId },
})
expect(unknown.statusCode).toBe(404)
expect(unknown.json()).toEqual({ error: 'person_not_found' })
await app.close()
})
it('refuses the assignment when the tree quota is exceeded', async () => {
const app = createApp()
const token = await createUserToken(app)
// L'arbre est déjà plein : 1 Mo sur le disque, un quota de 1 Mo.
await mkdir(path.join(storageRoot, 'tree-quota'), { recursive: true })
await writeFile(path.join(storageRoot, 'tree-quota', 'big.bin'), Buffer.alloc(1024 * 1024))
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-quota', avatarPath: null })
mockImmichPerson()
const response = await app.inject({
method: 'POST',
url: '/trees/tree-quota/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-quota', immichPersonId },
})
expect(response.statusCode).toBe(413)
expect(response.json()).toEqual({ error: 'storage_quota_exceeded' })
await app.close()
})
it('removes the new file and keeps the old avatar when the database update fails', async () => {
const app = createApp()
const token = await createUserToken(app)
const oldAvatarRelative = 'tree-1/person-tx/avatar-kept.jpg'
await mkdir(path.dirname(await absStoragePath(oldAvatarRelative)), { recursive: true })
await writeFile(await absStoragePath(oldAvatarRelative), 'old avatar')
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-tx', avatarPath: oldAvatarRelative })
mockImmichPerson()
prismaMock.$transaction.mockRejectedValue(new Error('db down'))
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/assign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-tx', immichPersonId, replaceAvatar: true },
})
expect(response.statusCode).toBe(500)
// La base n'a pas changé : l'ancien portrait est là, le nouveau a été nettoyé.
await expect(fsExists(await absStoragePath(oldAvatarRelative))).resolves.toBe(true)
const writtenFiles = await fsListDir(path.join(storageRoot, 'tree-1', 'person-tx'))
expect(writtenFiles.filter((name) => name.startsWith('immich-'))).toEqual([])
await app.close()
})
})
describe('Immich integration: unassign', () => {
it('clears an Immich avatar after the database update', async () => {
const app = createApp()
const token = await createUserToken(app)
const immichAvatarRelative = `tree-1/person-1/immich-${randomUUID()}.jpg`
await mkdir(path.dirname(await absStoragePath(immichAvatarRelative)), { recursive: true })
await writeFile(await absStoragePath(immichAvatarRelative), 'immich avatar')
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: immichAvatarRelative, immichPersonId })
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/unassign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1' },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ ok: true, avatarRemoved: true })
const updateData = prismaMock.person.update.mock.calls[0][0].data
expect(updateData).toMatchObject({ immichPersonId: null, avatarPath: null, avatarMimeType: null })
expect(prismaMock.auditLog.create).toHaveBeenCalledWith({
data: {
treeId: 'tree-1',
actorType: 'user',
actorId: 'user-1',
action: 'immich_unassign',
entityType: 'person',
entityId: 'person-1',
payloadJson: { previousImmichPersonId: immichPersonId, avatarRemoved: true },
},
})
await expect(fsExists(await absStoragePath(immichAvatarRelative))).resolves.toBe(false)
await app.close()
})
it('preserves a manually uploaded avatar when unassigning', async () => {
const app = createApp()
const token = await createUserToken(app)
const manualAvatarRelative = `tree-1/person-1/avatar-${randomUUID()}.jpg`
await mkdir(path.dirname(await absStoragePath(manualAvatarRelative)), { recursive: true })
await writeFile(await absStoragePath(manualAvatarRelative), 'manual avatar')
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: manualAvatarRelative, immichPersonId })
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/unassign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1' },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ ok: true, avatarRemoved: false })
const updateData = prismaMock.person.update.mock.calls[0][0].data
expect(updateData.immichPersonId).toBeNull()
expect(updateData).not.toHaveProperty('avatarPath')
expect(updateData).not.toHaveProperty('avatarMimeType')
await expect(fsExists(await absStoragePath(manualAvatarRelative))).resolves.toBe(true)
await app.close()
})
it('does nothing when no association is in place', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: null, immichPersonId: null })
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/unassign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1' },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ ok: true, avatarRemoved: false })
expect(prismaMock.person.update).not.toHaveBeenCalled()
expect(prismaMock.auditLog.create).not.toHaveBeenCalled()
await app.close()
})
it('rejects unknown persons and non-admin members', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.person.findFirst.mockResolvedValue(null)
const unknown = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/unassign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-x' },
})
expect(unknown.statusCode).toBe(404)
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'member',
tree: { deletedAt: null },
})
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: null, immichPersonId })
const member = await app.inject({
method: 'POST',
url: '/trees/tree-1/immich/unassign',
headers: { authorization: `Bearer ${token}` },
payload: { personId: 'person-1' },
})
expect(member.statusCode).toBe(403)
await app.close()
})
})
describe('Immich integration: manual avatar replacing an Immich portrait', () => {
it('clears the association when a manual file overwrites the Immich portrait', async () => {
const app = createApp()
const token = await createUserToken(app)
const oldAvatarRelative = `tree-1/person-1/immich-${randomUUID()}.jpg`
await writeFile(await absStoragePath(oldAvatarRelative), jpegBytes)
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: oldAvatarRelative, immichPersonId })
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/persons/person-1/avatar',
headers: { authorization: `Bearer ${token}` },
payload: {
fileName: 'portrait.jpg',
mimeType: 'image/jpeg',
sizeBytes: jpegBytes.length,
dataBase64: jpegBytes.toString('base64'),
},
})
expect(response.statusCode).toBe(201)
expect(response.json()).toEqual({ ok: true })
// Le portrait manuel remplace le fichier Immich et son association : sinon l'association
// survivrait invisible en base, sans qu'aucune interface ne puisse la retirer.
const updateData = prismaMock.person.updateMany.mock.calls[0][0].data
expect(updateData.immichPersonId).toBe(null)
expect(updateData.avatarPath).toEqual(expect.any(String))
expect(updateData.avatarPath).not.toContain('immich-')
// Le retrait du lien apparaît dans le journal d'audit de l'envoi.
const auditData = prismaMock.auditLog.create.mock.calls[0][0].data
expect(auditData.action).toBe('person_avatar_updated')
expect(auditData.payloadJson).toEqual({ immichLinkRemoved: true })
await app.close()
})
it('does not touch the association field when there is none', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: null, immichPersonId: null })
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/persons/person-1/avatar',
headers: { authorization: `Bearer ${token}` },
payload: {
fileName: 'portrait.jpg',
mimeType: 'image/jpeg',
sizeBytes: jpegBytes.length,
dataBase64: jpegBytes.toString('base64'),
},
})
expect(response.statusCode).toBe(201)
const updateData = prismaMock.person.updateMany.mock.calls[0][0].data
expect(updateData).not.toHaveProperty('immichPersonId')
await app.close()
})
})
describe('Immich integration: redirections', () => {
it('never follows a redirect and never sends the API key to another host', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/api/people')) {
return new Response(null, { status: 302, headers: { location: 'http://evil.test/collect' } })
}
return jsonResponse({ res: 'pong' })
})
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/people',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(503)
expect(response.json()).toEqual({ error: 'immich_unreachable' })
// Chaque appel part en mode « erreur » sur redirection, et aucun n'atteint l'hôte de la redirection.
expect(fetchMock.mock.calls.length).toBeGreaterThan(0)
for (const [input, init] of fetchMock.mock.calls) {
expect(init?.redirect).toBe('error')
expect(String(input)).not.toContain('evil.test')
}
await app.close()
})
})
describe('Immich integration: adresse de l’instance', () => {
it('keeps the sub-path of the instance in front of the API paths', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.treeImmichConfig.findUnique.mockResolvedValueOnce({ apiUrl: 'http://immich.test/immich', apiKeyEnc: treeApiKeyEnc })
fetchMock.mockResolvedValue(await jsonResponse({ people: [], total: 0, hasNextPage: false, hidden: 0 }))
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/people',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(String(fetchMock.mock.calls[0][0])).toMatch(/^http:\/\/immich\.test\/immich\/api\/people\?/)
await app.close()
})
it('compares origin and path, never a prefix of the host or user information', async () => {
const { isImmichApiUrlAllowed, normalizeImmichApiUrl } = await import('../src/lib/immich.js')
expect(isImmichApiUrlAllowed('http://immich.test/')).toBe(true)
expect(isImmichApiUrlAllowed('http://immich.test/immich')).toBe(true)
// Domaine préfixe : le nom listé est le début d'un autre hôte.
expect(isImmichApiUrlAllowed('http://immich.test.evil.com')).toBe(false)
// Chemin différent sur le même hôte.
expect(isImmichApiUrlAllowed('http://immich.test/autre')).toBe(false)
// Informations d'utilisateur : l'hôte réel est evil.com.
expect(isImmichApiUrlAllowed('http://immich.test@evil.com')).toBe(false)
expect(isImmichApiUrlAllowed('http://immich.test:x@immich.test')).toBe(false)
// Requête et fragment ne sont pas une adresse d'instance.
expect(isImmichApiUrlAllowed('http://immich.test/?x=1')).toBe(false)
expect(normalizeImmichApiUrl('ftp://immich.test')).toBeNull()
})
})
describe('Immich integration: durcissement des vignettes et des réponses', () => {
it('serves only images, with nosniff, and refuses bytes that are not an image', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(jpegBytes), { status: 200 }))
const image = await app.inject({
method: 'GET',
url: `/trees/tree-1/immich/thumbnail/${immichPersonId}`,
headers: { authorization: `Bearer ${token}` },
})
expect(image.statusCode).toBe(200)
expect(image.headers['content-type']).toBe('image/jpeg')
expect(image.headers['x-content-type-options']).toBe('nosniff')
// Un corps HTML déclaré image n'est jamais servi comme tel.
fetchMock.mockResolvedValueOnce(new Response('<script>alert(1)</script>', { status: 200, headers: { 'content-type': 'image/png' } }))
const notImage = await app.inject({
method: 'GET',
url: `/trees/tree-1/immich/thumbnail/${immichPersonId}`,
headers: { authorization: `Bearer ${token}` },
})
expect(notImage.statusCode).toBe(404)
expect(notImage.json()).toEqual({ error: 'thumbnail_not_found' })
await app.close()
})
it('stops reading a thumbnail above 5 MB without loading the rest of it', async () => {
const app = createApp()
const token = await createUserToken(app)
// Un flux sans taille annoncée, plus grand que la limite : la lecture doit s'arrêter en route.
const big = new Uint8Array(5 * 1024 * 1024 + 1024)
big.set(jpegBytes.subarray(0, Math.min(jpegBytes.length, big.length)))
fetchMock.mockResolvedValueOnce(new Response(big, { status: 200 }))
const response = await app.inject({
method: 'GET',
url: `/trees/tree-1/immich/thumbnail/${immichPersonId}`,
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(404)
expect(response.json()).toEqual({ error: 'thumbnail_not_found' })
await app.close()
})
it('answers 503 when an Immich response body cannot be read as JSON', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValue(new Response('<html>passerelle en panne</html>', { status: 200 }))
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/people',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(503)
expect(response.json()).toEqual({ error: 'immich_unreachable' })
await app.close()
})
it('clears the Immich link when the portrait is deleted by hand', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: 'tree-1/person-1/avatar-a.jpg', immichPersonId })
const response = await app.inject({
method: 'DELETE',
url: '/trees/tree-1/persons/person-1/avatar',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls[0][0].data).toMatchObject({ avatarPath: null, immichPersonId: null })
await app.close()
})
})
describe('Immich integration: lien d’une fiche', () => {
it('tells an administrator which Immich person a file is linked to, and refuses others', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.person.findFirst.mockResolvedValue({ immichPersonId })
const linked = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/links/person-1',
headers: { authorization: `Bearer ${token}` },
})
expect(linked.statusCode).toBe(200)
expect(linked.json()).toEqual({ immichPersonId })
const memberToken = await createUserToken(app, 'user-member')
prismaMock.treeMembership.findUnique.mockResolvedValueOnce({ treeId: 'tree-1', userId: 'user-member', role: 'member', tree: { deletedAt: null } })
const member = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/links/person-1',
headers: { authorization: `Bearer ${memberToken}` },
})
expect(member.statusCode).toBe(403)
prismaMock.person.findFirst.mockResolvedValue(null)
const missing = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/links/person-unknown',
headers: { authorization: `Bearer ${token}` },
})
expect(missing.statusCode).toBe(404)
await app.close()
})
})
describe('Immich integration: suppression logique d’une personne', () => {
it('clears the Immich link together with the portrait', async () => {
const { softDeletePersonCascade } = await import('../src/lib/entity-deletion.js')
const calls: Array<{ model: string; method: string; args: unknown }> = []
const model = (name: string) => ({
findFirst: async (args: unknown) => {
calls.push({ model: name, method: 'findFirst', args })
return { avatarPath: 'tree-1/person-1/immich-a.jpg' }
},
findMany: async () => [],
updateMany: async (args: unknown) => {
calls.push({ model: name, method: 'updateMany', args })
return { count: 0 }
},
})
const db = new Proxy({} as Record<string, unknown>, {
get: (_target, name: string) => model(name),
}) as never
await softDeletePersonCascade(db, 'tree-1', 'person-1', 'user-1', new Date('2026-10-16T00:00:00Z'))
const personUpdate = calls.find((call) => call.model === 'person' && call.method === 'updateMany')
expect(personUpdate?.args).toMatchObject({
data: { deletedAt: new Date('2026-10-16T00:00:00Z'), avatarPath: null, immichPersonId: null },
})
})
})