Sécurité : bcrypt natif hors de la boucle de l'API ; un souvenir en attente ne se lit que par le gestionnaire et son auteur (#160)

This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-26 12:10:55 +01:00
1 parent 00aff350f1
commit 124ceb1c3e
7 files changed
+478 -48

No files matched your search

+297 -40
View File
@@ -12,8 +12,8 @@
"@fastify/cors": "^11.3.0",
"@fastify/jwt": "^10.2.5",
"@fastify/rate-limit": "^11.2.0",
"@node-rs/bcrypt": "^1.10.7",
"@prisma/client": "^6.15.0",
"bcryptjs": "^3.0.2",
"dotenv": "^18.0.8",
"fastify": "^5.12.7",
"openid-client": "^6.8.8",
@@ -28,6 +28,17 @@
"vitest": "^5.0.5"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
@@ -38,6 +49,16 @@
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -1226,6 +1247,271 @@
"node": ">=8"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@node-rs/bcrypt": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt/-/bcrypt-1.10.7.tgz",
"integrity": "sha512-1wk0gHsUQC/ap0j6SJa2K34qNhomxXRcEe3T8cI5s+g6fgHBgLTN7U9LzWTG/HE6G4+2tWWLeCabk1wiYGEQSA==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@node-rs/bcrypt-android-arm-eabi": "1.10.7",
"@node-rs/bcrypt-android-arm64": "1.10.7",
"@node-rs/bcrypt-darwin-arm64": "1.10.7",
"@node-rs/bcrypt-darwin-x64": "1.10.7",
"@node-rs/bcrypt-freebsd-x64": "1.10.7",
"@node-rs/bcrypt-linux-arm-gnueabihf": "1.10.7",
"@node-rs/bcrypt-linux-arm64-gnu": "1.10.7",
"@node-rs/bcrypt-linux-arm64-musl": "1.10.7",
"@node-rs/bcrypt-linux-x64-gnu": "1.10.7",
"@node-rs/bcrypt-linux-x64-musl": "1.10.7",
"@node-rs/bcrypt-wasm32-wasi": "1.10.7",
"@node-rs/bcrypt-win32-arm64-msvc": "1.10.7",
"@node-rs/bcrypt-win32-ia32-msvc": "1.10.7",
"@node-rs/bcrypt-win32-x64-msvc": "1.10.7"
}
},
"node_modules/@node-rs/bcrypt-android-arm-eabi": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm-eabi/-/bcrypt-android-arm-eabi-1.10.7.tgz",
"integrity": "sha512-8dO6/PcbeMZXS3VXGEtct9pDYdShp2WBOWlDvSbcRwVqyB580aCBh0BEFmKYtXLzLvUK8Wf+CG3U6sCdILW1lA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/bcrypt-android-arm64": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm64/-/bcrypt-android-arm64-1.10.7.tgz",
"integrity": "sha512-UASFBS/CucEMHiCtL/2YYsAY01ZqVR1N7vSb94EOvG5iwW7BQO06kXXCTgj+Xbek9azxixrCUmo3WJnkJZ0hTQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/bcrypt-darwin-arm64": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-arm64/-/bcrypt-darwin-arm64-1.10.7.tgz",
"integrity": "sha512-DgzFdAt455KTuiJ/zYIyJcKFobjNDR/hnf9OS7pK5NRS13Nq4gLcSIIyzsgHwZHxsJWbLpHmFc1H23Y7IQoQBw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/bcrypt-darwin-x64": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-x64/-/bcrypt-darwin-x64-1.10.7.tgz",
"integrity": "sha512-SPWVfQ6sxSokoUWAKWD0EJauvPHqOGQTd7CxmYatcsUgJ/bruvEHxZ4bIwX1iDceC3FkOtmeHO0cPwR480n/xA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/bcrypt-freebsd-x64": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-freebsd-x64/-/bcrypt-freebsd-x64-1.10.7.tgz",
"integrity": "sha512-gpa+Ixs6GwEx6U6ehBpsQetzUpuAGuAFbOiuLB2oo4N58yU4AZz1VIcWyWAHrSWRs92O0SHtmo2YPrMrwfBbSw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/bcrypt-linux-arm-gnueabihf": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm-gnueabihf/-/bcrypt-linux-arm-gnueabihf-1.10.7.tgz",
"integrity": "sha512-kYgJnTnpxrzl9sxYqzflobvMp90qoAlaX1oDL7nhNTj8OYJVDIk0jQgblj0bIkjmoPbBed53OJY/iu4uTS+wig==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/bcrypt-linux-arm64-gnu": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-gnu/-/bcrypt-linux-arm64-gnu-1.10.7.tgz",
"integrity": "sha512-7cEkK2RA+gBCj2tCVEI1rDSJV40oLbSq7bQ+PNMHNI6jCoXGmj9Uzo7mg7ZRbNZ7piIyNH5zlJqutjo8hh/tmA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/bcrypt-linux-arm64-musl": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-musl/-/bcrypt-linux-arm64-musl-1.10.7.tgz",
"integrity": "sha512-X7DRVjshhwxUqzdUKDlF55cwzh+wqWJ2E/tILvZPboO3xaNO07Um568Vf+8cmKcz+tiZCGP7CBmKbBqjvKN/Pw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/bcrypt-linux-x64-gnu": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-gnu/-/bcrypt-linux-x64-gnu-1.10.7.tgz",
"integrity": "sha512-LXRZsvG65NggPD12hn6YxVgH0W3VR5fsE/o1/o2D5X0nxKcNQGeLWnRzs5cP8KpoFOuk1ilctXQJn8/wq+Gn/Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/bcrypt-linux-x64-musl": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-musl/-/bcrypt-linux-x64-musl-1.10.7.tgz",
"integrity": "sha512-tCjHmct79OfcO3g5q21ME7CNzLzpw1MAsUXCLHLGWH+V6pp/xTvMbIcLwzkDj6TI3mxK6kehTn40SEjBkZ3Rog==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/bcrypt-wasm32-wasi": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-wasm32-wasi/-/bcrypt-wasm32-wasi-1.10.7.tgz",
"integrity": "sha512-4qXSihIKeVXYglfXZEq/QPtYtBUvR8d3S85k15Lilv3z5B6NSGQ9mYiNleZ7QHVLN2gEc5gmi7jM353DMH9GkA==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^0.2.5"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@node-rs/bcrypt-win32-arm64-msvc": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-arm64-msvc/-/bcrypt-win32-arm64-msvc-1.10.7.tgz",
"integrity": "sha512-FdfUQrqmDfvC5jFhntMBkk8EI+fCJTx/I1v7Rj+Ezlr9rez1j1FmuUnywbBj2Cg15/0BDhwYdbyZ5GCMFli2aQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/bcrypt-win32-ia32-msvc": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-ia32-msvc/-/bcrypt-win32-ia32-msvc-1.10.7.tgz",
"integrity": "sha512-lZLf4Cx+bShIhU071p5BZft4OvP4PGhyp542EEsb3zk34U5GLsGIyCjOafcF/2DGewZL6u8/aqoxbSuROkgFXg==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/bcrypt-win32-x64-msvc": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-x64-msvc/-/bcrypt-win32-x64-msvc-1.10.7.tgz",
"integrity": "sha512-hdw7tGmN1DxVAMTzICLdaHpXjy+4rxaxnBMgI8seG1JL5e3VcRGsd1/1vVDogVp2cbsmgq+6d6yAY+D9lW/DCg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@oxc-project/types": {
"version": "0.154.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
@@ -1444,9 +1730,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1465,9 +1748,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1486,9 +1766,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1507,9 +1784,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1528,9 +1802,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1549,9 +1820,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1631,6 +1899,16 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
"integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -2130,15 +2408,6 @@
"fastq": "^1.17.1"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
@@ -2925,9 +3194,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2950,9 +3216,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2975,9 +3238,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3000,9 +3260,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
+1 -1
View File
@@ -21,8 +21,8 @@
"@fastify/cors": "^11.3.0",
"@fastify/jwt": "^10.2.5",
"@fastify/rate-limit": "^11.2.0",
"@node-rs/bcrypt": "^1.10.7",
"@prisma/client": "^6.15.0",
"bcryptjs": "^3.0.2",
"dotenv": "^18.0.8",
"fastify": "^5.12.7",
"openid-client": "^6.8.8",
+7 -4
View File
@@ -1,15 +1,18 @@
import bcrypt from 'bcryptjs'
// bcrypt natif (binaire précompilé) : le calcul passe sur les fils de libuv, la boucle de l'API garde la main.
// bcryptjs calculait en JavaScript : 8 vérifications simultanées retardaient chaque requête de 0,7 s en moyenne.
// Mêmes empreintes $2a$/$2b$ : les mots de passe enregistrés restent valables.
import { hash, verify } from '@node-rs/bcrypt'
import { env } from '../config/env.js'
const HASH_ROUNDS = 12
export async function hashPassword(value: string): Promise<string> {
return bcrypt.hash(value, HASH_ROUNDS)
return hash(value, HASH_ROUNDS)
}
export async function verifyPassword(value: string, hash: string): Promise<boolean> {
return bcrypt.compare(value, hash)
export async function verifyPassword(value: string, storedHash: string): Promise<boolean> {
return verify(value, storedHash)
}
// Un compte à mot de passe se confirme avec son mot de passe seulement si la connexion par mot de passe est ouverte.
+3 -3
View File
@@ -50,9 +50,9 @@ export function resetTreeUnlockGuard(): void {
attemptsByTree.clear()
}
// bcryptjs calcule en JavaScript sur le processus de l'API (≈ 250 ms par comparaison au coût 12) : des essais
// lancés en parallèle depuis quelques adresses suffisaient à le saturer pour toutes les familles. Les
// comparaisons de mot de passe de partage passent une par une, le reste de l'API garde la main entre deux.
// Une comparaison bcrypt coûte ≈ 250 ms au coût 12. Même calculée hors de la boucle (bcrypt natif), des essais
// lancés en parallèle depuis quelques adresses occuperaient tous les fils de calcul, connexions comprises. Les
// comparaisons de mot de passe de partage passent une par une.
// Au-delà de MAX_WAITING comparaisons en attente, on refuse tout de suite (null) plutôt que d'allonger la file.
const MAX_WAITING = 20
let queue: Promise<unknown> = Promise.resolve()
+24
View File
@@ -241,6 +241,18 @@ async function canReadMedia(app: FastifyRequest['server'], treeId: string, reque
return token ? canReadTreeWithMediaToken(app, treeId, token) : false
}
// Compte qui demande un média : celui de la session, sinon celui du jeton médias (lien de partage : aucun)
async function mediaRequesterUserId(app: FastifyRequest['server'], request: FastifyRequest, token: string | undefined): Promise<string | null> {
if (request.actor?.kind === 'user') return request.actor.userId
if (!token) return null
try {
const payload = await app.jwt.verify<{ kind?: string; userId?: string }>(token)
return payload.kind === 'media' && payload.userId ? payload.userId : null
} catch {
return null
}
}
export const mediaRoutes: FastifyPluginAsync = async (app) => {
app.post('/trees/:id/persons/:personId/media', UPLOAD_RATE_LIMIT, async (request, reply) => {
const actor = request.actor
@@ -1079,6 +1091,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
select: {
filePath: true,
mimeType: true,
status: true,
uploadedBy: true,
},
})
@@ -1086,6 +1100,16 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.code(404).send({ error: 'media_not_found' })
}
// Un souvenir qui attend la relecture ne se lit que par qui gère l'arbre ou par qui l'a envoyé, pas par toute la
// famille qui connaîtrait son identifiant. Même réponse qu'un média absent : rien n'indique qu'il existe.
if (media.status === 'pending') {
const userId = await mediaRequesterUserId(app, request, query.data.token)
const allowed = Boolean(userId) && (media.uploadedBy === userId || Boolean(await requireAdminMembership(app, userId as string, params.data.id)))
if (!allowed) {
return reply.code(404).send({ error: 'media_not_found' })
}
}
// YouTube videos: filePath is the YouTube URL, redirect the browser
if (media.mimeType === 'video/youtube') {
return reply.code(302).redirect(media.filePath)
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { hashPassword, verifyPassword } from '../src/lib/auth.js'
// bcrypt natif à la place de bcryptjs : les empreintes déjà en base restent valables
describe('mots de passe, bcrypt natif', () => {
it('lit une empreinte faite par bcryptjs', async () => {
const fromBcryptjs = '$2b$10$4uBVFoNEA8gO3uIGq/RqMueaq4w2Vl9VzPFiSj9v9dc0oI1DcR.YS'
expect(await verifyPassword('123soleil 🌞', fromBcryptjs)).toBe(true)
expect(await verifyPassword('123soleil', fromBcryptjs)).toBe(false)
})
it('fait une empreinte au coût 12 qui se relit', async () => {
const stored = await hashPassword('mot-de-passe-solide')
expect(stored).toMatch(/^\$2[aby]\$12\$/)
expect(await verifyPassword('mot-de-passe-solide', stored)).toBe(true)
expect(await verifyPassword('mot-de-passe-faux', stored)).toBe(false)
})
})
+128
View File
@@ -0,0 +1,128 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
// Souvenir en attente de relecture, sur une vraie base : seuls qui gère l'arbre et qui l'a envoyé le lisent,
// la famille qui connaîtrait son identifiant reçoit la même réponse qu'un média absent. Lancé seulement avec
// TEST_DATABASE_URL (base jetable, migrée, en UTF-8) :
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/media-attente.db.test.ts
const TEST_DB = process.env.TEST_DATABASE_URL
let app: FastifyInstance
let env: { ALLOW_REGISTRATION: boolean; MEDIA_STORAGE_PATH: string }
let ip = 0
const nextIp = () => `10.3.${Math.floor(++ip / 250)}.${ip % 250 + 1}`
const run = Date.now().toString(36)
const mail = (name: string) => `${name}-${run}@exemple.fr`
// PNG d'un pixel
const PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
const storage = mkdtempSync(path.join(tmpdir(), 'pth-media-attente-'))
async function call(method: string, url: string, token?: string, payload?: unknown) {
const res = await app.inject({
method: method as 'GET',
url,
payload: payload as Record<string, unknown>,
headers: token ? { authorization: `Bearer ${token}` } : {},
remoteAddress: nextIp(),
})
let body = null
try { body = res.body ? res.json() : null } catch { body = null }
return { status: res.statusCode, body }
}
async function account(name: string) {
const res = await call('POST', '/auth/register', undefined, { email: mail(name), password: 'mot-de-passe-solide', firstName: name })
expect(res.status).toBe(201)
return { token: res.body.token as string, id: res.body.user.id as string }
}
describe.skipIf(!TEST_DB)('souvenir en attente, vraie base', () => {
let owner: { token: string; id: string }
let member: { token: string; id: string }
let reader: { token: string; id: string }
let shareToken: string
let treeId: string
let personId: string
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.DATABASE_URL = TEST_DB
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.CORS_ORIGIN = 'http://localhost:5173'
process.env.MEDIA_STORAGE_PATH = storage
;({ env } = await import('../src/config/env.js'))
const { createApp } = await import('../src/app.js')
app = createApp()
env.ALLOW_REGISTRATION = true
env.MEDIA_STORAGE_PATH = storage
owner = await account('proprio')
const tree = await call('POST', '/trees', owner.token, { name: 'Arbre de démo', slug: `attente-${run}`, visitorPassword: 'partage-demo-1', contributorPassword: 'partage-demo-1' })
expect(tree.status).toBe(201)
treeId = tree.body.tree?.id ?? tree.body.id
const person = await call('POST', `/trees/${treeId}/persons`, owner.token, { firstName: 'Odette', lastName: 'Duval' })
expect(person.status).toBe(201)
personId = person.body.person?.id ?? person.body.id
// Deux comptes de la famille, rattachés par le mot de passe de partage, et le lien de partage seul
member = await account('membre')
reader = await account('lecteur')
for (const who of [member, reader]) {
expect((await call('POST', `/trees/${treeId}/access/unlock`, who.token, { password: 'partage-demo-1' })).status).toBe(200)
}
const shared = await call('POST', `/trees/${treeId}/access/unlock`, undefined, { password: 'partage-demo-1' })
expect(shared.status).toBe(200)
shareToken = shared.body.token
})
afterAll(async () => {
await app?.close()
})
const upload = (token: string) => call('POST', `/trees/${treeId}/persons/${personId}/media`, token, {
type: 'photo', fileName: 'photo.png', mimeType: 'image/png', sizeBytes: Buffer.from(PIXEL, 'base64').length, dataBase64: PIXEL,
})
const read = (mediaId: string, token?: string) => call('GET', `/trees/${treeId}/media/${mediaId}`, token)
// Jeton médias, celui que porte l'adresse d'une image (<img src>) : émis avec le graphe de l'arbre
const mediaToken = async (token: string) => (await call('GET', `/trees/${treeId}/graph`, token)).body?.mediaToken as string
it('un souvenir envoyé par un membre attend la relecture, et seuls le gestionnaire et son auteur le lisent', async () => {
const sent = await upload(member.token)
expect(sent.status).toBe(201)
const media = sent.body.media ?? sent.body
expect(media.status).toBe('pending')
expect((await read(media.id, owner.token)).status).toBe(200)
expect((await read(media.id, member.token)).status).toBe(200)
expect((await read(media.id, reader.token)).status).toBe(404)
expect((await read(media.id, shareToken)).status).toBe(404)
expect((await read(media.id)).status).toBe(401)
// Par l'adresse d'une image : le jeton médias du gestionnaire ouvre, celui du lecteur non
expect((await read(`${media.id}?token=${await mediaToken(owner.token)}`)).status).toBe(200)
expect((await read(`${media.id}?token=${await mediaToken(member.token)}`)).status).toBe(200)
expect((await read(`${media.id}?token=${await mediaToken(reader.token)}`)).status).toBe(404)
expect((await read(`${media.id}?token=${await mediaToken(shareToken)}`)).status).toBe(404)
})
it('un souvenir accepté se lit par toute la famille', async () => {
const sent = await upload(member.token)
const media = sent.body.media ?? sent.body
expect(media.status).toBe('pending')
const session = sent.body.contributionSessionId ?? media.contributionSessionId
expect(session).toBeTruthy()
const reviewed = await call('POST', `/trees/${treeId}/contributions/sessions/${session}/review`, owner.token, { decision: 'approved' })
expect(reviewed.status).toBe(200)
expect((await read(media.id, reader.token)).status).toBe(200)
expect((await read(media.id, shareToken)).status).toBe(200)
})
it('un souvenir posé par le gestionnaire est visible tout de suite', async () => {
const sent = await upload(owner.token)
expect(sent.status).toBe(201)
const media = sent.body.media ?? sent.body
expect(media.status).toBe('approved')
expect((await read(media.id, reader.token)).status).toBe(200)
})
})