Files
parenthese/backend/test/data-deletion.test.ts
T
IliesAllaliandClaude Opus 5 0c4cc20c97 Suppression des données par les utilisateurs, CI et auto-hébergement vérifié
Utilisateurs
- Suppression de compte depuis l'écran « Votre compte », confirmée par mot de passe :
  arbres possédés et médias effacés, email retiré des contributions, sessions fermées partout.
- Suppression d'arbre définitive, en base et sur le disque.
- Ajout d'un frère ou d'une sœur relié aux parents existants ; relations parent et enfant
  corrigées en mode démo.
- Liste d'attente retirée (route, envoi d'emails, dépendance Resend).

Projet ouvert
- CI : front, landing, backend avec vérification des migrations, test Docker de bout en bout.
- Déploiement lancé seulement après une CI réussie sur main.
- Image API : CLI Prisma installée à part pour que les migrations tournent au démarrage.
- SECURITY.md, CONTRIBUTING.md, modèles d'issue et de pull request.
- Migration renommée avec un nom descriptif, contenu identique.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:22:26 +02:00

358 lines
12 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { existsSync } from 'node:fs'
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'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
user: {
findUnique: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
delete: vi.fn(),
},
tree: {
findMany: vi.fn(),
findFirst: vi.fn(),
deleteMany: vi.fn(),
},
treeMembership: {
findUnique: vi.fn(),
},
treeAccessPasswords: {
findUnique: vi.fn(),
},
contributionSession: {
updateMany: vi.fn(),
},
$transaction: vi.fn(),
$disconnect: vi.fn(),
},
}))
vi.mock('../src/lib/prisma.js', () => ({
prisma: prismaMock,
}))
const PASSWORD = 'correct-password'
// user-1 possède deux arbres (dont un déjà marqué supprimé) et n'est que membre du troisième.
const TREES = [
{ id: 'tree-owned', ownerUserId: 'user-1' },
{ id: 'tree-owned-archived', ownerUserId: 'user-1' },
{ id: 'tree-member', ownerUserId: 'user-2' },
]
let storageRoot = ''
let passwordHash = ''
let createApp: () => FastifyInstance
let resolveTreeMediaDirectory: (treeId: string, root?: string) => string | null
function applyTestEnv(): void {
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'
process.env.MEDIA_STORAGE_PATH = storageRoot
}
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 writeMediaFile(treeId: string, relativePath: string): Promise<string> {
const filePath = path.join(storageRoot, treeId, relativePath)
await mkdir(path.dirname(filePath), { recursive: true })
await writeFile(filePath, 'media')
return filePath
}
beforeAll(async () => {
storageRoot = await mkdtemp(path.join(tmpdir(), 'parenthese-purge-'))
applyTestEnv()
const authModule = await import('../src/lib/auth.js')
passwordHash = await authModule.hashPassword(PASSWORD)
createApp = (await import('../src/app.js')).createApp
resolveTreeMediaDirectory = (await import('../src/lib/tree-purge.js')).resolveTreeMediaDirectory
})
afterAll(async () => {
await rm(storageRoot, { recursive: true, force: true })
})
beforeEach(async () => {
vi.clearAllMocks()
await rm(storageRoot, { recursive: true, force: true })
await mkdir(storageRoot, { recursive: true })
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', passwordHash })
prismaMock.user.count.mockResolvedValue(1)
prismaMock.user.updateMany.mockResolvedValue({ count: 0 })
prismaMock.user.delete.mockResolvedValue({ id: 'user-1' })
prismaMock.tree.findMany.mockImplementation(async (args: any) => (
TREES.filter((tree) => tree.ownerUserId === args?.where?.ownerUserId).map(({ id }) => ({ id }))
))
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-owned' })
prismaMock.tree.deleteMany.mockImplementation(async (args: any) => ({ count: args?.where?.id?.in?.length ?? 0 }))
prismaMock.treeMembership.findUnique.mockResolvedValue({ treeId: 'tree-owned', userId: 'user-1', role: 'owner' })
prismaMock.treeAccessPasswords.findUnique.mockResolvedValue(null)
prismaMock.contributionSession.updateMany.mockResolvedValue({ count: 0 })
prismaMock.$transaction.mockImplementation(async (callback: (tx: typeof prismaMock) => Promise<unknown>) => callback(prismaMock))
prismaMock.$disconnect.mockResolvedValue(undefined)
})
describe('DELETE /auth/me', () => {
it('requires an authenticated user', async () => {
const app = createApp()
const response = await app.inject({
method: 'DELETE',
url: '/auth/me',
payload: { password: PASSWORD },
})
expect(response.statusCode).toBe(401)
expect(prismaMock.$transaction).not.toHaveBeenCalled()
await app.close()
})
it('rejects a missing password', async () => {
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({
method: 'DELETE',
url: '/auth/me',
headers: { authorization: `Bearer ${token}` },
payload: {},
})
expect(response.statusCode).toBe(400)
expect(prismaMock.$transaction).not.toHaveBeenCalled()
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()
const token = await createUserToken(app)
const response = await app.inject({
method: 'DELETE',
url: '/auth/me',
headers: { authorization: `Bearer ${token}` },
payload: { password: 'wrong-password' },
})
expect(response.statusCode).toBe(403)
expect(response.json()).toEqual({ error: 'invalid_password' })
expect(prismaMock.$transaction).not.toHaveBeenCalled()
expect(prismaMock.tree.deleteMany).not.toHaveBeenCalled()
expect(prismaMock.user.delete).not.toHaveBeenCalled()
expect(existsSync(ownedFile)).toBe(true)
// Le jeton reste valide après un mot de passe erroné.
const lastTreeResponse = await app.inject({
method: 'GET',
url: '/auth/me/last-tree',
headers: { authorization: `Bearer ${token}` },
})
expect(lastTreeResponse.statusCode).toBe(200)
await app.close()
})
it('deletes the user, owned trees and their media, keeps other trees, revokes the token', async () => {
const ownedFile = await writeMediaFile('tree-owned', 'person-1/photo.jpg')
const archivedFile = await writeMediaFile('tree-owned-archived', 'annotation-photos/note.png')
const memberFile = await writeMediaFile('tree-member', 'person-9/photo.jpg')
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({
method: 'DELETE',
url: '/auth/me',
headers: { authorization: `Bearer ${token}` },
payload: { password: PASSWORD },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ ok: true })
expect(prismaMock.tree.findMany).toHaveBeenCalledWith({
where: { ownerUserId: 'user-1' },
select: { id: true },
})
expect(prismaMock.tree.deleteMany).toHaveBeenCalledTimes(1)
expect(prismaMock.tree.deleteMany).toHaveBeenCalledWith({
where: { id: { in: ['tree-owned', 'tree-owned-archived'] } },
})
expect(prismaMock.user.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: { lastOpenedTreeId: { in: ['tree-owned', 'tree-owned-archived'] } },
}))
expect(prismaMock.contributionSession.updateMany).toHaveBeenCalledWith({
where: { submittedByUserId: 'user-1' },
data: { submittedByLabel: 'Contributeur' },
})
expect(prismaMock.user.delete).toHaveBeenCalledWith({ where: { id: 'user-1' } })
// Les arbres doivent partir avant l'utilisateur (relation propriétaire en Restrict).
expect(prismaMock.tree.deleteMany.mock.invocationCallOrder[0])
.toBeLessThan(prismaMock.user.delete.mock.invocationCallOrder[0])
expect(existsSync(ownedFile)).toBe(false)
expect(existsSync(path.join(storageRoot, 'tree-owned'))).toBe(false)
expect(existsSync(archivedFile)).toBe(false)
expect(existsSync(memberFile)).toBe(true)
prismaMock.user.findUnique.mockResolvedValue(null)
prismaMock.user.count.mockResolvedValue(0)
const afterResponse = await app.inject({
method: 'GET',
url: '/auth/me/last-tree',
headers: { authorization: `Bearer ${token}` },
})
expect(afterResponse.statusCode).toBe(401)
const treesResponse = await app.inject({
method: 'GET',
url: '/trees',
headers: { authorization: `Bearer ${token}` },
})
expect(treesResponse.statusCode).toBe(401)
await app.close()
})
it('keeps the response successful when media removal fails', async () => {
prismaMock.tree.findMany.mockResolvedValue([{ id: '..' }])
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({
method: 'DELETE',
url: '/auth/me',
headers: { authorization: `Bearer ${token}` },
payload: { password: PASSWORD },
})
expect(response.statusCode).toBe(200)
expect(existsSync(storageRoot)).toBe(true)
await app.close()
})
it('rejects a session opened on another device once the account is gone', async () => {
const app = createApp()
const otherDeviceToken = await createUserToken(app)
prismaMock.user.count.mockResolvedValue(0)
const response = await app.inject({
method: 'GET',
url: '/trees',
headers: { authorization: `Bearer ${otherDeviceToken}` },
})
expect(response.statusCode).toBe(401)
expect(prismaMock.user.count).toHaveBeenCalledWith({ where: { id: 'user-1' } })
await app.close()
})
})
describe('DELETE /trees/:id', () => {
it('permanently deletes the tree and its media directory', async () => {
const ownedFile = await writeMediaFile('tree-owned', 'person-1/photo.jpg')
const otherFile = await writeMediaFile('tree-member', 'person-9/photo.jpg')
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({
method: 'DELETE',
url: '/trees/tree-owned',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({ ok: true })
expect(prismaMock.tree.deleteMany).toHaveBeenCalledWith({
where: { id: { in: ['tree-owned'] } },
})
expect(prismaMock.user.updateMany).toHaveBeenCalledWith({
where: { lastOpenedTreeId: { in: ['tree-owned'] } },
data: {
lastOpenedTreeId: null,
lastOpenedAccessMode: null,
lastOpenedRole: null,
lastOpenedAt: null,
},
})
expect(existsSync(ownedFile)).toBe(false)
expect(existsSync(otherFile)).toBe(true)
await app.close()
})
it('forbids a non-owner member', async () => {
const ownedFile = await writeMediaFile('tree-owned', 'person-1/photo.jpg')
prismaMock.treeMembership.findUnique.mockResolvedValue({ treeId: 'tree-owned', userId: 'user-1', role: 'admin' })
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({
method: 'DELETE',
url: '/trees/tree-owned',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(403)
expect(prismaMock.tree.deleteMany).not.toHaveBeenCalled()
expect(existsSync(ownedFile)).toBe(true)
await app.close()
})
it('returns 404 for a tree already marked as deleted', async () => {
prismaMock.tree.findFirst.mockResolvedValue(null)
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({
method: 'DELETE',
url: '/trees/tree-owned',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(404)
expect(prismaMock.tree.deleteMany).not.toHaveBeenCalled()
await app.close()
})
})
describe('resolveTreeMediaDirectory', () => {
it('accepts a plain tree id directly under the storage root', () => {
expect(resolveTreeMediaDirectory('tree-1', storageRoot)).toBe(path.join(storageRoot, 'tree-1'))
})
it('rejects ids that would escape or target the storage root', () => {
for (const treeId of ['', '.', '..', '../other', 'a/b', 'a\\b', '..\\other']) {
expect(resolveTreeMediaDirectory(treeId, storageRoot)).toBeNull()
}
})
})