Files
parenthese/backend/test/data-deletion.test.ts
T
IliesAllaliandClaude Opus 5.5 65900a4372 feat(comptes): connexion par un fournisseur OIDC (#115)
OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET et OIDC_REDIRECT_URI
ajoutent « Se connecter avec … » à l'écran de connexion. Tout le protocole
passe par openid-client 6 : découverte, PKCE S256, state et nonce, échange
du code, vérification de l'ID token (signature, émetteur, audience, dates).
State, nonce et vérificateur PKCE vivent dans un cookie signé, httpOnly,
SameSite=Lax, qui lie le retour au navigateur qui a commencé la connexion.

Le compte est trouvé par (émetteur, sub), sinon relié par email vérifié,
sinon créé sans mot de passe si ALLOW_REGISTRATION le permet (instance
vide comprise). Retour vers l'app par un jeton de passage de 60 s, à usage
unique, dans le fragment, échangé contre une session.

PASSWORD_LOGIN=false retire les mots de passe Parenthèse. Un compte sans
mot de passe se supprime en retapant son email, dans les 15 minutes qui
suivent une connexion. Code OIDC masqué dans les journaux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:26:01 +02:00

430 lines
15 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(),
},
treeVisit: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
$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()
})
describe("compte sans mot de passe (né d'une connexion OIDC)", () => {
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(() => {
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash: null })
})
it('se supprime avec son email retapé, juste après une connexion', 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 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(response.json()).toEqual({ error: 'invalid_password' })
expect(prismaMock.user.delete).not.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("n'accepte pas un email à la place du mot de passe pour un compte qui en a un", async () => {
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash })
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(403)
expect(prismaMock.user.delete).not.toHaveBeenCalled()
await app.close()
}, 15000)
})
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' },
})
// Ses visites d'autres arbres restent comptées, sans son identifiant
const visitUpdate = prismaMock.treeVisit.updateMany.mock.calls[0][0]
expect(visitUpdate.where).toEqual({ visitorKey: 'user:user-1' })
expect(visitUpdate.data.visitorKey).toMatch(/^[0-9a-f]{32}$/)
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()
}
})
})