Files
parenthese/backend/test/data-deletion.test.ts
T
IliesAllaliandClaude Haiku 5.5 25b33ca056 fix(comptes) : suppression sans mot de passe si PASSWORD_LOGIN=false, identité OIDC liée par sub
Suppression de compte : un compte qui avait un mot de passe mais qui est relié à OIDC, sur une instance avec
PASSWORD_LOGIN=false, ne pouvait plus se supprimer (le mot de passe ne sert plus). Il se confirme désormais par
son email après une connexion récente, comme un compte né du fournisseur. /me et l'échange OIDC renvoient
hasPassword=false dans ce cas, pour que l'écran demande l'email : aucun changement d'interface.

Identité OIDC : un email vérifié déjà relié à une autre identité (un autre sujet) n'est plus repris par un
nouveau sujet (oidc_email_taken). La connexion d'une identité déjà reliée reste faite par émetteur et sujet,
jamais par email.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-21 15:51:47 +02:00

499 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, afterEach, 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
let env: { PASSWORD_LOGIN: boolean }
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
;({ env } = await import('../src/config/env.js'))
})
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)
})
describe('compte à mot de passe relié à OIDC, connexion par mot de passe fermée (PASSWORD_LOGIN=false)', () => {
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(() => {
env.PASSWORD_LOGIN = false
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash })
})
afterEach(() => {
env.PASSWORD_LOGIN = true
})
it('se supprime avec son email retapé, juste après une connexion (le mot de passe ne peut plus servir)', 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 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('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(prismaMock.user.delete).not.toHaveBeenCalled()
await app.close()
})
it('dit à l’écran de demander l’email, pas un mot de passe (hasPassword=false)', async () => {
const app = createApp()
const response = await app.inject({
method: 'GET', url: '/auth/me',
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
})
expect(response.statusCode).toBe(200)
expect(response.json().user.hasPassword).toBe(false)
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' },
})
// 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()
}
})
})