mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-09 23:50:58 +00:00
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>
358 lines
12 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
})
|