mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Merge pull request #126 from IliesAllali/fix/1.2.1-relecture
1.2.1 : export GEDCOM 5.5.1 valide, suppression de compte OIDC, identité OIDC par sub, HEIC reconnu
This commit is contained in:
11 files changed
+213
-21
No files matched your search
+10
-1
@@ -1,5 +1,7 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
const HASH_ROUNDS = 12
|
||||
|
||||
export async function hashPassword(value: string): Promise<string> {
|
||||
@@ -8,4 +10,11 @@ export async function hashPassword(value: string): Promise<string> {
|
||||
|
||||
export async function verifyPassword(value: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(value, hash)
|
||||
}
|
||||
}
|
||||
|
||||
// Un compte à mot de passe se confirme avec son mot de passe seulement si la connexion par mot de passe est ouverte.
|
||||
// Avec PASSWORD_LOGIN=false, un compte relié à un fournisseur OIDC (et qui avait un mot de passe) ne peut plus le
|
||||
// redonner : il se confirme par son email, comme un compte né du fournisseur, après une connexion récente.
|
||||
export function confirmsWithPassword(passwordHash: string | null | undefined): boolean {
|
||||
return Boolean(passwordHash) && env.PASSWORD_LOGIN
|
||||
}
|
||||
@@ -59,6 +59,9 @@ const EXTENSION_BY_MIME: Record<string, string> = {
|
||||
'application/gpx+xml': '.gpx',
|
||||
}
|
||||
|
||||
// Marques ISO BMFF des images HEIF/HEIC (ISO/IEC 23008-12)
|
||||
const HEIC_BRANDS = new Set(['heic', 'heix', 'mif1', 'msf1', 'heim', 'heis'])
|
||||
|
||||
function startsWith(buf: Buffer, bytes: number[], offset = 0): boolean {
|
||||
if (buf.length < offset + bytes.length) return false
|
||||
return bytes.every((byte, index) => buf[offset + index] === byte)
|
||||
@@ -89,6 +92,10 @@ export function sniffMimeType(buf: Buffer, declaredType: BinaryMediaType, fileNa
|
||||
if (ascii(buf, 4, 8) === 'ftyp') {
|
||||
const brand = ascii(buf, 8, 12)
|
||||
if (brand === 'qt ') return 'video/quicktime'
|
||||
// HEIF (photos iPhone et Android) : pas une vidéo, et refusée à l'envoi faute de conversion (sharp ne décode
|
||||
// pas le HEVC). « hevc » peut aussi désigner une vidéo HEVC en MP4 : photo seulement si l'envoi se déclare photo.
|
||||
if (HEIC_BRANDS.has(brand) || (brand === 'hevc' && declaredType === 'photo')) return 'image/heic'
|
||||
if (brand === 'avif' || brand === 'avis') return 'image/avif'
|
||||
if (declaredType === 'audio' || brand === 'M4A ' || brand === 'M4B ') return 'audio/mp4'
|
||||
return 'video/mp4'
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
import { hashPassword, verifyPassword } from '../lib/auth.js'
|
||||
import { confirmsWithPassword, hashPassword, verifyPassword } from '../lib/auth.js'
|
||||
import { isOidcEnabled } from '../lib/oidc.js'
|
||||
import { createAccountIfAllowed, isRegistrationOpen } from '../lib/registration.js'
|
||||
import { signUserSession } from '../lib/user-session.js'
|
||||
@@ -237,7 +237,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
|
||||
// hasPassword : l'écran de suppression demande le mot de passe, ou l'email pour un compte sans mot de passe
|
||||
return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: Boolean(user.passwordHash) } })
|
||||
return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash) } })
|
||||
})
|
||||
|
||||
app.get('/me/last-tree', async (request, reply) => {
|
||||
@@ -379,17 +379,19 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
const payload = await request.jwtVerify<AnyJwtPayload & { exp?: number; iat?: number }>()
|
||||
|
||||
if (!user.passwordHash) {
|
||||
// Compte né d'une connexion OIDC : pas de mot de passe à redonner. Il retape son email, et la session doit
|
||||
// venir d'une connexion récente chez le fournisseur (une session volée ou oubliée ouverte ne suffit pas).
|
||||
const withPassword = confirmsWithPassword(user.passwordHash)
|
||||
if (!withPassword) {
|
||||
// Compte né d'une connexion OIDC, ou dont la connexion par mot de passe est fermée : pas de mot de passe à
|
||||
// redonner. Il retape son email, et la session doit venir d'une connexion récente chez le fournisseur
|
||||
// (une session volée ou oubliée ouverte ne suffit pas).
|
||||
const sessionAgeMs = typeof payload.iat === 'number' ? Date.now() - payload.iat * 1000 : Infinity
|
||||
if (sessionAgeMs > RECENT_SIGN_IN_MS) {
|
||||
return reply.code(403).send({ error: 'recent_sign_in_required' })
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = user.passwordHash
|
||||
? Boolean(parsed.data.password) && await verifyPassword(parsed.data.password as string, user.passwordHash)
|
||||
const isValid = withPassword
|
||||
? Boolean(parsed.data.password) && await verifyPassword(parsed.data.password as string, user.passwordHash as string)
|
||||
: (parsed.data.confirmEmail ?? '').trim().toLowerCase() === user.email
|
||||
if (!isValid) {
|
||||
return reply.code(403).send({ error: 'invalid_password' })
|
||||
|
||||
@@ -8,7 +8,7 @@ import { indexGedcomRecords, mediaRefsForPerson, noteText, photoPathForPerson }
|
||||
import type { GedcomRecords } from '../lib/gedcom-photos.js'
|
||||
import type { MembershipRole } from '../types/auth.js'
|
||||
import { nextSiblingOrder } from '../lib/sibling-order.js'
|
||||
import { sexOrNull } from '../lib/person-sex.js'
|
||||
import { sexOrNull, type PersonSex } from '../lib/person-sex.js'
|
||||
|
||||
const treeIdParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
@@ -285,6 +285,15 @@ function pairKey(a: string, b: string | null): string {
|
||||
return b ? [a, b].sort().join('|') : `${a}|`
|
||||
}
|
||||
|
||||
// GEDCOM 5.5.1 n'admet que M, F et U (inconnu, autre). « X » n'existe qu'en 7.0 : un sexe « autre » s'écrit U,
|
||||
// et l'import relit X comme avant.
|
||||
export function gedcomSexValue(sex: PersonSex | null): 'M' | 'F' | 'U' | null {
|
||||
if (sex === 'male') return 'M'
|
||||
if (sex === 'female') return 'F'
|
||||
if (sex === 'other') return 'U'
|
||||
return null
|
||||
}
|
||||
|
||||
function sanitizeXref(id: string): string {
|
||||
return id.replace(/[^A-Za-z0-9_]/g, '_')
|
||||
}
|
||||
@@ -456,10 +465,8 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
lines.push(`1 NAME ${gedcomNameValue(person.firstName, person.lastName)}`)
|
||||
if (person.nickname) lines.push(`2 NICK ${oneLine(person.nickname)}`)
|
||||
|
||||
const sex = sexOrNull(person.sex)
|
||||
if (sex === 'male') lines.push('1 SEX M')
|
||||
else if (sex === 'female') lines.push('1 SEX F')
|
||||
else if (sex === 'other') lines.push('1 SEX X')
|
||||
const sexValue = gedcomSexValue(sexOrNull(person.sex))
|
||||
if (sexValue) lines.push(`1 SEX ${sexValue}`)
|
||||
|
||||
const personEvents = eventsByPerson.get(person.id) ?? []
|
||||
const birthDetails = personEvents.find((event) => event.type === 'birth')
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ImmichPersonSummary,
|
||||
} from '../lib/immich.js'
|
||||
import { decryptImmichApiKey, encryptImmichApiKey } from '../lib/immich-secret.js'
|
||||
import { InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js'
|
||||
import { IMAGE_MIME_TYPES, InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js'
|
||||
import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile, requireAdminMembership } from './media.js'
|
||||
|
||||
// Reconnaissance des visages : associer une personne de l'arbre à une personne Immich, dont la
|
||||
@@ -315,7 +315,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
// Le type se lit dans les octets, pas dans ce qu'Immich déclare : la vignette finit dans une
|
||||
// balise <img>. Vide, trop grande ou pas une image : rien à montrer.
|
||||
const mimeType = thumbnail?.length ? sniffMimeType(thumbnail, 'photo') : null
|
||||
if (!thumbnail || !mimeType?.startsWith('image/')) {
|
||||
if (!thumbnail || !mimeType || !(IMAGE_MIME_TYPES as readonly string[]).includes(mimeType)) {
|
||||
request.log.warn('Vignette Immich vide, trop grande ou qui n’est pas une image')
|
||||
return reply.code(404).send({ error: 'thumbnail_not_found' })
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type OidcIdentity,
|
||||
type OidcPending,
|
||||
} from '../lib/oidc.js'
|
||||
import { confirmsWithPassword } from '../lib/auth.js'
|
||||
import { createAccountIfAllowed } from '../lib/registration.js'
|
||||
import { signUserSession } from '../lib/user-session.js'
|
||||
|
||||
@@ -64,7 +65,10 @@ async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Pro
|
||||
if (!identity.email) return { error: 'oidc_email_missing' }
|
||||
if (env.OIDC_REQUIRE_VERIFIED_EMAIL && !identity.emailVerified) return { error: 'oidc_email_unverified' }
|
||||
|
||||
const existing = await app.prisma.user.findUnique({ where: { email: identity.email }, select: { id: true, passwordHash: true } })
|
||||
const existing = await app.prisma.user.findUnique({
|
||||
where: { email: identity.email },
|
||||
select: { id: true, passwordHash: true, identities: { select: { id: true }, take: 1 } },
|
||||
})
|
||||
if (existing) {
|
||||
// Relier un compte existant demande toujours un email vérifié, même avec OIDC_REQUIRE_VERIFIED_EMAIL=false :
|
||||
// sinon quiconque déclare l'adresse d'un autre chez le fournisseur prendrait son compte
|
||||
@@ -72,6 +76,9 @@ async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Pro
|
||||
// Jamais vers un compte à mot de passe : l'inscription ne vérifie pas l'adresse, un tiers aurait pu créer ce
|
||||
// compte avec l'email de la victime, et sa connexion par le fournisseur y serait reliée, lisible par le tiers
|
||||
if (existing.passwordHash) return { error: 'oidc_email_taken' }
|
||||
// Un compte déjà relié à une autre identité (un autre sujet) n'est jamais repris par un nouveau sujet qui
|
||||
// présente le même email : le fournisseur a pu réattribuer l'adresse. Le compte reste à celui qui l'a relié.
|
||||
if (existing.identities?.length) return { error: 'oidc_email_taken' }
|
||||
|
||||
await app.prisma.userIdentity.create({
|
||||
data: { userId: existing.id, issuer: identity.issuer, subject: identity.subject },
|
||||
@@ -210,7 +217,7 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
return reply.send({
|
||||
token: signUserSession(app, user),
|
||||
user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: Boolean(user.passwordHash) },
|
||||
user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash) },
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -921,7 +921,7 @@ describe('Sexe dans le GEDCOM (issue #114)', () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('aller-retour : other sort en SEX X, non précisé sans ligne SEX, et tout se relit à l’identique', async () => {
|
||||
it('aller-retour : other sort en SEX U (GEDCOM 5.5.1), non précisé sans ligne SEX, le reste se relit à l’identique', async () => {
|
||||
const app = createApp()
|
||||
await app.ready()
|
||||
const token = (app as any).jwt.sign({ kind: 'tree_access', sub: 'tree:tree-1:visitor', treeId: 'tree-1', role: 'visitor', accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime() })
|
||||
@@ -940,10 +940,12 @@ describe('Sexe dans le GEDCOM (issue #114)', () => {
|
||||
const response = await app.inject({ method: 'GET', url: '/trees/tree-1/export/gedcom', headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
const text = response.body.replace(/\r\n/g, '\n')
|
||||
expect(text).toContain('1 NAME Camille /Martin/\n1 SEX X\n')
|
||||
expect(text).toContain('1 NAME Camille /Martin/\n1 SEX U\n')
|
||||
expect(text).not.toContain('SEX X')
|
||||
expect(text).toMatch(/1 NAME Claude \/Martin\/\n(?!1 SEX)/)
|
||||
|
||||
expect(await importer(app, text)).toEqual({ Jean: 'male', Marie: 'female', Camille: 'other', Claude: null })
|
||||
// SEX U se relit comme non précisé : 5.5.1 n'a pas de valeur pour « autre ». SEX X reste lu à l'import (ligne 915).
|
||||
expect(await importer(app, text)).toEqual({ Jean: 'male', Marie: 'female', Camille: null, Claude: null })
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
@@ -53,6 +53,7 @@ let storageRoot = ''
|
||||
let passwordHash = ''
|
||||
let createApp: () => FastifyInstance
|
||||
let resolveTreeMediaDirectory: (treeId: string, root?: string) => string | null
|
||||
let env: { PASSWORD_LOGIN: boolean }
|
||||
|
||||
function applyTestEnv(): void {
|
||||
process.env.NODE_ENV = 'test'
|
||||
@@ -97,6 +98,7 @@ beforeAll(async () => {
|
||||
|
||||
createApp = (await import('../src/app.js')).createApp
|
||||
resolveTreeMediaDirectory = (await import('../src/lib/tree-purge.js')).resolveTreeMediaDirectory
|
||||
;({ env } = await import('../src/config/env.js'))
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -220,6 +222,73 @@ describe('DELETE /auth/me', () => {
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
describe('compte à mot de passe relié à OIDC, connexion par mot de passe fermée (PASSWORD_LOGIN=false)', () => {
|
||||
const signedAt = async (app: FastifyInstance, minutesAgo: number) => {
|
||||
await app.ready()
|
||||
return (app as any).jwt.sign({
|
||||
kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'user@example.com',
|
||||
iat: Math.floor(Date.now() / 1000) - minutesAgo * 60,
|
||||
}, { expiresIn: '1h' })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
env.PASSWORD_LOGIN = false
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
env.PASSWORD_LOGIN = true
|
||||
})
|
||||
|
||||
it('se supprime avec son email retapé, juste après une connexion (le mot de passe ne peut plus servir)', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'DELETE', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
|
||||
payload: { confirmEmail: 'user@example.com' },
|
||||
})
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(prismaMock.user.delete).toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuse une session de plus de 15 minutes, même avec le bon email', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'DELETE', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 20)}` },
|
||||
payload: { confirmEmail: 'user@example.com' },
|
||||
})
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(response.json()).toEqual({ error: 'recent_sign_in_required' })
|
||||
expect(prismaMock.user.delete).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuse un autre email', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'DELETE', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
|
||||
payload: { confirmEmail: 'autre@example.com' },
|
||||
})
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(prismaMock.user.delete).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('dit à l’écran de demander l’email, pas un mot de passe (hasPassword=false)', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'GET', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
|
||||
})
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.json().user.hasPassword).toBe(false)
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a wrong password without deleting anything', async () => {
|
||||
const ownedFile = await writeMediaFile('tree-owned', 'person-1/photo.jpg')
|
||||
const app = createApp()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseSex } from '../src/lib/person-sex'
|
||||
import { gedcomSexValue, parseGedcom } from '../src/routes/gedcom'
|
||||
|
||||
// Export en GEDCOM 5.5.1 : SEX ne vaut que M, F ou U (#118). « X » n'existe qu'en GEDCOM 7.
|
||||
describe('export GEDCOM 5.5.1 : valeur SEX', () => {
|
||||
it('écrit M, F, et U pour un sexe « autre »', () => {
|
||||
expect(gedcomSexValue('male')).toBe('M')
|
||||
expect(gedcomSexValue('female')).toBe('F')
|
||||
expect(gedcomSexValue('other')).toBe('U')
|
||||
})
|
||||
|
||||
it("n'écrit jamais X, et ne déclare rien pour un sexe non précisé", () => {
|
||||
for (const sex of ['male', 'female', 'other', null] as const) {
|
||||
expect(gedcomSexValue(sex)).not.toBe('X')
|
||||
}
|
||||
expect(gedcomSexValue(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('l’import relit toujours SEX X comme « autre », et U comme non précisé', () => {
|
||||
const sexes = parseGedcom(['0 @I1@ INDI', '1 SEX X', '0 @I2@ INDI', '1 SEX U'].join('\n'))
|
||||
.filter((node) => node.tag === 'INDI')
|
||||
.map((indi) => parseSex(indi.children.find((child) => child.tag === 'SEX')?.value ?? null))
|
||||
expect(sexes).toEqual(['other', null])
|
||||
})
|
||||
})
|
||||
@@ -168,6 +168,29 @@ describe('compte relié, créé ou refusé', () => {
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('identité déjà reliée : retrouvée par émetteur et sujet, jamais par email (le fournisseur a pu le réattribuer)', async () => {
|
||||
prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-9' })
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, email: 'reattribue@example.com' })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(prismaMock.userIdentity.findUnique).toHaveBeenCalledWith({
|
||||
where: { issuer_subject: { issuer: ISSUER, subject: 'sub-1' } },
|
||||
select: { userId: true },
|
||||
})
|
||||
expect(prismaMock.user.findUnique).not.toHaveBeenCalled()
|
||||
expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy()
|
||||
})
|
||||
|
||||
it("nouveau sujet avec un email vérifié déjà relié à un autre sujet : refusé, le compte ne change pas de mains", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7', passwordHash: null, identities: [{ id: 'identity-1' }] })
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, subject: 'sub-2' })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_taken')
|
||||
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("email vérifié d'un compte à mot de passe : refusé, ni relié ni dupliqué (le compte a pu être créé par un tiers)", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7', passwordHash: 'hash' })
|
||||
const { cookie } = await start()
|
||||
|
||||
@@ -49,6 +49,45 @@ describe('fichiers envoyés : type lu dans les octets', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Boîte ftyp minimale : taille, « ftyp », marque principale
|
||||
function ftypBox(brand: string): Buffer {
|
||||
const box = Buffer.alloc(24)
|
||||
box.writeUInt32BE(24, 0)
|
||||
box.write('ftyp', 4, 'latin1')
|
||||
box.write(brand, 8, 'latin1')
|
||||
return box
|
||||
}
|
||||
|
||||
describe('HEIC et AVIF : reconnus, jamais pris pour une vidéo', () => {
|
||||
it.each(['heic', 'heix', 'mif1', 'msf1', 'heim', 'heis'])('marque %s : image/heic, refusée à l’envoi', (brand) => {
|
||||
expect(sniffMimeType(ftypBox(brand), 'photo', 'IMG_0001.HEIC')).toBe('image/heic')
|
||||
expect(accepted(ftypBox(brand), 'photo', 'IMG_0001.HEIC')).toBeNull()
|
||||
expect(accepted(ftypBox(brand), 'video', 'IMG_0001.MOV')).toBeNull()
|
||||
})
|
||||
|
||||
it('marque avif : image/avif, refusée à l’envoi (pas de conversion)', () => {
|
||||
expect(sniffMimeType(ftypBox('avif'), 'photo')).toBe('image/avif')
|
||||
expect(accepted(ftypBox('avif'), 'photo')).toBeNull()
|
||||
})
|
||||
|
||||
it('marque hevc : image/heic quand l’envoi se déclare photo, vidéo MP4 sinon (une vidéo HEVC reste acceptée)', () => {
|
||||
expect(sniffMimeType(ftypBox('hevc'), 'photo')).toBe('image/heic')
|
||||
expect(accepted(ftypBox('hevc'), 'video')).toBe('video/mp4')
|
||||
})
|
||||
|
||||
it('les vidéos MP4 et QuickTime ne changent pas', () => {
|
||||
expect(accepted(ftypBox('isom'), 'video')).toBe('video/mp4')
|
||||
expect(accepted(ftypBox('mp42'), 'video')).toBe('video/mp4')
|
||||
expect(accepted(ftypBox('qt '), 'video')).toBe('video/quicktime')
|
||||
expect(accepted(ftypBox('M4A '), 'audio')).toBe('audio/mp4')
|
||||
})
|
||||
|
||||
it('une image HEIC n’entre dans aucun type de média autorisé', () => {
|
||||
expect(isMimeAllowedForType('photo', 'image/heic')).toBe(false)
|
||||
expect(isMimeAllowedForType('photo', 'image/avif')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('photos : métadonnées retirées, rotation appliquée', () => {
|
||||
it('retire le GPS et tourne les pixels selon l orientation du téléphone', async () => {
|
||||
const withGps = await sharp({ create: { width: 64, height: 48, channels: 3, background: { r: 200, g: 120, b: 60 } } })
|
||||
|
||||
Reference in new issue
Block a user