diff --git a/backend/package-lock.json b/backend/package-lock.json index e78da18..d280fd1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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": [ diff --git a/backend/package.json b/backend/package.json index 1a0f622..7a37385 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index 60b5737..f087af3 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -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 { - return bcrypt.hash(value, HASH_ROUNDS) + return hash(value, HASH_ROUNDS) } -export async function verifyPassword(value: string, hash: string): Promise { - return bcrypt.compare(value, hash) +export async function verifyPassword(value: string, storedHash: string): Promise { + 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. diff --git a/backend/src/lib/unlock-guard.ts b/backend/src/lib/unlock-guard.ts index 4a6ceff..16e4bfe 100644 --- a/backend/src/lib/unlock-guard.ts +++ b/backend/src/lib/unlock-guard.ts @@ -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 = Promise.resolve() diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index e387573..e611edd 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -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 { + 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) diff --git a/backend/test/auth-bcrypt.test.ts b/backend/test/auth-bcrypt.test.ts new file mode 100644 index 0000000..ac76ed8 --- /dev/null +++ b/backend/test/auth-bcrypt.test.ts @@ -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) + }) +}) diff --git a/backend/test/media-attente.db.test.ts b/backend/test/media-attente.db.test.ts new file mode 100644 index 0000000..d4b23be --- /dev/null +++ b/backend/test/media-attente.db.test.ts @@ -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, + 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 () : é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) + }) +})