mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-12 08:16:09 +00:00
Immich : vérifier l'existence d'une personne avant l'association
Le 404 de la vignette ne distingue pas une personne inexistante d'une
vignette manquante : l'association pouvait donc être enregistrée pour
une personne Immich qui n'existe pas.
- ImmichClient.getPerson() : GET /api/people/{id} (200 | 404), qui
établit l'existence de la personne et annonce sa vignette.
- La route d'association rejette une personne inexistante (404
immich_person_not_found) ; un refus d'authentification (502) ou une
erreur serveur (503) n'est jamais pris pour une absence.
- La vignette n'est demandée que si Immich en annonce une ; une vignette
manquante chez une personne existante associe toujours sans portrait.
- Tests : personne inexistante refusée sans toucher base, audit ni
fichiers ; vignette disparue entre les deux appels ; échec
d'authentification pendant l'association sans changement en base.
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
1 parent
2f64ceb41d
commit
c703a34165
3 files changed
+141
-10
No files matched your search
@@ -134,6 +134,22 @@ export class ImmichClient {
|
||||
return people
|
||||
}
|
||||
|
||||
// Une personne précise : 200 avec ses données, 404 si elle n'existe pas. Le 404 de la
|
||||
// vignette ne prouve rien (personne absente ou simple vignette manquante), l'existence
|
||||
// se vérifie donc ici.
|
||||
async getPerson(personId: string): Promise<ImmichPersonSummary> {
|
||||
const response = await this.fetch(`/api/people/${encodeURIComponent(personId)}`)
|
||||
const person = (await response.json()) as { id?: unknown; name?: unknown; thumbnailPath?: unknown }
|
||||
if (typeof person?.id !== 'string' || typeof person?.name !== 'string') {
|
||||
throw new ImmichUnavailableError('Réponse Immich inattendue sur /api/people/{id}')
|
||||
}
|
||||
return {
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
hasThumbnail: typeof person.thumbnailPath === 'string' && person.thumbnailPath.length > 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Vignette d'une personne, en octets bruts (JPEG côté Immich). Absente -> ImmichNotFoundError.
|
||||
async getPersonThumbnail(personId: string): Promise<Buffer> {
|
||||
const response = await this.fetch(`/api/people/${encodeURIComponent(personId)}/thumbnail`)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ImmichAuthError,
|
||||
ImmichNotFoundError,
|
||||
ImmichUnavailableError,
|
||||
type ImmichPersonSummary,
|
||||
} from '../lib/immich.js'
|
||||
import { InvalidMediaError, removeStoredFile, sniffMimeType } from '../lib/media-files.js'
|
||||
import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile, requireAdminMembership } from './media.js'
|
||||
@@ -184,15 +185,28 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(404).send({ error: 'person_not_found' })
|
||||
}
|
||||
|
||||
// Vignette de la personne Immich : absente -> on associe sans toucher au portrait existant.
|
||||
let thumbnail: Buffer | null = null
|
||||
// La personne Immich doit exister : le 404 de la vignette ne le prouve pas (personne
|
||||
// absente ou simple vignette manquante), on la vérifie donc explicitement. Un refus
|
||||
// d'authentification ou une erreur serveur ne sont jamais pris pour une absence.
|
||||
let immichPerson: ImmichPersonSummary
|
||||
try {
|
||||
thumbnail = await client.getPersonThumbnail(immichPersonId)
|
||||
immichPerson = await client.getPerson(immichPersonId)
|
||||
} catch (error) {
|
||||
if (!(error instanceof ImmichNotFoundError)) {
|
||||
return replyImmichError(request, reply, error)
|
||||
return replyImmichError(request, reply, error)
|
||||
}
|
||||
|
||||
// Vignette de la personne Immich : absente -> on associe sans toucher au portrait existant.
|
||||
// Demandée seulement si Immich en annonce une : sans vignette, l'appel est inutile.
|
||||
let thumbnail: Buffer | null = null
|
||||
if (immichPerson.hasThumbnail) {
|
||||
try {
|
||||
thumbnail = await client.getPersonThumbnail(immichPersonId)
|
||||
} catch (error) {
|
||||
if (!(error instanceof ImmichNotFoundError)) {
|
||||
return replyImmichError(request, reply, error)
|
||||
}
|
||||
// Vignette absente : l'association se fait sans portrait.
|
||||
}
|
||||
// Vignette absente : l'association se fait sans portrait.
|
||||
}
|
||||
|
||||
if (!thumbnail) {
|
||||
|
||||
+105
-4
@@ -46,6 +46,20 @@ 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 })
|
||||
})
|
||||
}
|
||||
|
||||
async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise<string> {
|
||||
await app.ready()
|
||||
return (app as any).jwt.sign(
|
||||
@@ -305,7 +319,7 @@ describe('Immich integration: assign', () => {
|
||||
await writeFile(await absStoragePath(oldAvatarRelative), 'old avatar')
|
||||
|
||||
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: oldAvatarRelative })
|
||||
fetchMock.mockResolvedValue(new Response(new Uint8Array(jpegBytes), { status: 200 }))
|
||||
mockImmichPerson()
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -346,7 +360,36 @@ describe('Immich integration: assign', () => {
|
||||
const token = await createUserToken(app)
|
||||
|
||||
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: 'tree-1/person-1/avatar-keep.jpg' })
|
||||
fetchMock.mockResolvedValue(new Response('', { status: 404 }))
|
||||
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',
|
||||
@@ -365,6 +408,64 @@ describe('Immich integration: assign', () => {
|
||||
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)
|
||||
@@ -426,7 +527,7 @@ describe('Immich integration: assign', () => {
|
||||
await writeFile(path.join(storageRoot, 'tree-quota', 'big.bin'), Buffer.alloc(1024 * 1024))
|
||||
|
||||
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-quota', avatarPath: null })
|
||||
fetchMock.mockResolvedValue(new Response(new Uint8Array(jpegBytes), { status: 200 }))
|
||||
mockImmichPerson()
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -449,7 +550,7 @@ describe('Immich integration: assign', () => {
|
||||
await writeFile(await absStoragePath(oldAvatarRelative), 'old avatar')
|
||||
|
||||
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-tx', avatarPath: oldAvatarRelative })
|
||||
fetchMock.mockResolvedValue(new Response(new Uint8Array(jpegBytes), { status: 200 }))
|
||||
mockImmichPerson()
|
||||
prismaMock.$transaction.mockRejectedValue(new Error('db down'))
|
||||
|
||||
const response = await app.inject({
|
||||
|
||||
Reference in new issue
Block a user