mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
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>
1217 lines
49 KiB
TypeScript
1217 lines
49 KiB
TypeScript
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 },
|
||
})
|
||
})
|
||
})
|