mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
* Comptes : une déconnexion reste valable après un redémarrage de l'API (sessions fermées gardées en base) * Déconnexion durable : sans rechargement des sessions fermées, l'API ne démarre pas ; modèle remonté au-dessus du commentaire des photos
1745 lines
57 KiB
TypeScript
1745 lines
57 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
||
import type { FastifyInstance } from 'fastify'
|
||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
|
||
const { prismaMock } = vi.hoisted(() => ({
|
||
prismaMock: {
|
||
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
|
||
revokedSession: {
|
||
upsert: vi.fn().mockResolvedValue({}),
|
||
findMany: vi.fn().mockResolvedValue([]),
|
||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||
},
|
||
user: {
|
||
findUnique: vi.fn(),
|
||
count: vi.fn().mockResolvedValue(1),
|
||
create: vi.fn(),
|
||
update: vi.fn(),
|
||
},
|
||
tree: {
|
||
findFirst: vi.fn(),
|
||
findUnique: vi.fn(),
|
||
create: vi.fn(),
|
||
updateMany: vi.fn(),
|
||
},
|
||
treeMembership: {
|
||
count: vi.fn(async () => 0),
|
||
findMany: vi.fn(),
|
||
findUnique: vi.fn(),
|
||
create: vi.fn(),
|
||
},
|
||
treeSettings: {
|
||
create: vi.fn(),
|
||
findUnique: vi.fn(),
|
||
upsert: vi.fn(),
|
||
},
|
||
treeAccessPasswords: {
|
||
findUnique: vi.fn(),
|
||
create: vi.fn(),
|
||
update: vi.fn(),
|
||
},
|
||
treeSharePassword: {
|
||
findUnique: vi.fn(),
|
||
upsert: vi.fn(),
|
||
deleteMany: vi.fn(),
|
||
},
|
||
userTreeAccess: {
|
||
findMany: vi.fn(),
|
||
findUnique: vi.fn(),
|
||
upsert: vi.fn(),
|
||
updateMany: vi.fn(), deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||
},
|
||
person: {
|
||
findMany: vi.fn(),
|
||
findFirst: vi.fn(),
|
||
create: vi.fn(),
|
||
count: vi.fn(),
|
||
updateMany: vi.fn(),
|
||
},
|
||
union: {
|
||
findMany: vi.fn(),
|
||
findFirst: vi.fn(),
|
||
create: vi.fn(),
|
||
count: vi.fn(),
|
||
updateMany: vi.fn(),
|
||
},
|
||
parentChildLink: {
|
||
findMany: vi.fn(),
|
||
findFirst: vi.fn(),
|
||
create: vi.fn(),
|
||
count: vi.fn(),
|
||
updateMany: vi.fn(),
|
||
},
|
||
mediaItem: {
|
||
count: vi.fn(), aggregate: vi.fn().mockResolvedValue({ _sum: { sizeBytes: 0 } }),
|
||
create: vi.fn(),
|
||
findMany: vi.fn(),
|
||
findFirst: vi.fn(),
|
||
update: vi.fn(),
|
||
updateMany: vi.fn(),
|
||
},
|
||
lifeEvent: {
|
||
findMany: vi.fn().mockResolvedValue([]),
|
||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||
},
|
||
annotation: {
|
||
findMany: vi.fn(),
|
||
},
|
||
contributionSession: {
|
||
create: vi.fn(),
|
||
findMany: vi.fn(),
|
||
findFirst: vi.fn(),
|
||
update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||
count: vi.fn(),
|
||
},
|
||
contributionChange: {
|
||
create: vi.fn(),
|
||
update: vi.fn(),
|
||
},
|
||
mediaPersonTag: {
|
||
findMany: vi.fn().mockResolvedValue([]),
|
||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||
},
|
||
auditLog: {
|
||
create: vi.fn(),
|
||
findMany: vi.fn(),
|
||
},
|
||
$transaction: vi.fn(),
|
||
$disconnect: vi.fn(),
|
||
},
|
||
}))
|
||
|
||
vi.mock('../src/lib/prisma.js', () => ({
|
||
prisma: prismaMock,
|
||
}))
|
||
|
||
let createApp: () => FastifyInstance
|
||
|
||
// Vraie image d'un pixel : le serveur reconnaît les fichiers à leurs octets, plus au type déclaré
|
||
const PNG_1PX = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==', 'base64')
|
||
|
||
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'
|
||
}
|
||
|
||
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',
|
||
},
|
||
)
|
||
}
|
||
|
||
beforeAll(async () => {
|
||
applyTestEnv()
|
||
const module = await import('../src/app.js')
|
||
createApp = module.createApp
|
||
})
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
prismaMock.userTreeAccess.deleteMany?.mockResolvedValue({ count: 0 })
|
||
prismaMock.contributionSession.updateMany?.mockResolvedValue({ count: 1 })
|
||
prismaMock.mediaItem.aggregate?.mockResolvedValue({ _sum: { sizeBytes: 0 } })
|
||
|
||
prismaMock.user.findUnique.mockResolvedValue(null)
|
||
prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: 'user@example.com' })
|
||
prismaMock.user.update.mockResolvedValue({ id: 'user-1' })
|
||
|
||
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null })
|
||
prismaMock.tree.findUnique.mockResolvedValue({ id: 'tree-1', slug: 'tree-1', name: 'Tree' })
|
||
prismaMock.tree.create.mockResolvedValue({ id: 'tree-1', slug: 'tree-1', name: 'Tree' })
|
||
prismaMock.tree.updateMany.mockResolvedValue({ count: 1 })
|
||
|
||
prismaMock.treeMembership.findMany.mockResolvedValue([])
|
||
prismaMock.treeMembership.findUnique.mockResolvedValue({
|
||
treeId: 'tree-1',
|
||
userId: 'user-1',
|
||
role: 'owner',
|
||
tree: { deletedAt: null, rootPersonId: null },
|
||
})
|
||
|
||
prismaMock.treeAccessPasswords.findUnique.mockResolvedValue({
|
||
treeId: 'tree-1',
|
||
visitorHash: 'hash',
|
||
contributorHash: 'hash',
|
||
updatedAt: new Date('2026-02-11T10:00:00.000Z'),
|
||
tree: { deletedAt: null },
|
||
})
|
||
prismaMock.userTreeAccess.findMany.mockResolvedValue([])
|
||
prismaMock.userTreeAccess.findUnique.mockResolvedValue(null)
|
||
prismaMock.userTreeAccess.upsert.mockResolvedValue({
|
||
id: 'share-1',
|
||
treeId: 'tree-1',
|
||
userId: 'user-1',
|
||
role: 'visitor',
|
||
})
|
||
prismaMock.userTreeAccess.updateMany.mockResolvedValue({ count: 0 })
|
||
prismaMock.treeAccessPasswords.update.mockResolvedValue({ treeId: 'tree-1' })
|
||
prismaMock.treeSharePassword.findUnique.mockResolvedValue(null)
|
||
prismaMock.treeSharePassword.upsert.mockResolvedValue({ treeId: 'tree-1' })
|
||
prismaMock.treeSharePassword.deleteMany.mockResolvedValue({ count: 0 })
|
||
prismaMock.treeSettings.findUnique.mockResolvedValue({
|
||
treeId: 'tree-1',
|
||
memberContributionPolicy: 'pending',
|
||
visitorPolicy: 'read_only',
|
||
contributorPolicy: 'pending',
|
||
})
|
||
prismaMock.treeSettings.upsert.mockResolvedValue({
|
||
treeId: 'tree-1',
|
||
memberContributionPolicy: 'direct',
|
||
visitorPolicy: 'read_only',
|
||
contributorPolicy: 'pending',
|
||
})
|
||
|
||
prismaMock.person.findMany.mockResolvedValue([])
|
||
prismaMock.person.findFirst.mockImplementation(async (args: any) => ({
|
||
id: args?.where?.id || 'person-1',
|
||
treeId: 'tree-1',
|
||
deletedAt: null,
|
||
}))
|
||
prismaMock.person.create.mockResolvedValue({ id: 'person-1' })
|
||
prismaMock.person.count.mockResolvedValue(2)
|
||
prismaMock.person.updateMany.mockResolvedValue({ count: 1 })
|
||
prismaMock.union.findMany.mockResolvedValue([
|
||
{
|
||
id: 'union-1',
|
||
partner1PersonId: 'person-1',
|
||
partner2PersonId: null,
|
||
treeId: 'tree-1',
|
||
deletedAt: null,
|
||
},
|
||
])
|
||
prismaMock.union.findFirst.mockResolvedValue({
|
||
id: 'union-1',
|
||
partner1PersonId: 'person-1',
|
||
partner2PersonId: null,
|
||
treeId: 'tree-1',
|
||
deletedAt: null,
|
||
})
|
||
prismaMock.union.create.mockResolvedValue({ id: 'union-1' })
|
||
prismaMock.union.count.mockResolvedValue(1)
|
||
prismaMock.union.updateMany.mockResolvedValue({ count: 1 })
|
||
prismaMock.parentChildLink.findMany.mockResolvedValue([])
|
||
prismaMock.parentChildLink.findFirst.mockResolvedValue({
|
||
id: 'link-1',
|
||
treeId: 'tree-1',
|
||
parentPersonId: 'person-1',
|
||
childPersonId: 'person-2',
|
||
viaUnionId: 'union-1',
|
||
deletedAt: null,
|
||
})
|
||
prismaMock.parentChildLink.create.mockResolvedValue({ id: 'link-1' })
|
||
prismaMock.parentChildLink.count.mockResolvedValue(1)
|
||
prismaMock.parentChildLink.updateMany.mockResolvedValue({ count: 1 })
|
||
prismaMock.mediaItem.count.mockResolvedValue(0)
|
||
prismaMock.mediaItem.create.mockResolvedValue({ id: 'media-1' })
|
||
prismaMock.mediaItem.findMany.mockResolvedValue([])
|
||
prismaMock.mediaItem.findFirst.mockResolvedValue({ id: 'media-1', filePath: 'tree-1/person-1/media-1.jpg', mimeType: 'image/jpeg' })
|
||
prismaMock.mediaItem.update.mockResolvedValue({ id: 'media-1' })
|
||
prismaMock.mediaItem.updateMany.mockResolvedValue({ count: 1 })
|
||
prismaMock.annotation.findMany.mockResolvedValue([])
|
||
prismaMock.contributionSession.create.mockResolvedValue({
|
||
id: 'session-1',
|
||
treeId: 'tree-1',
|
||
status: 'pending',
|
||
})
|
||
prismaMock.contributionSession.findMany.mockResolvedValue([])
|
||
prismaMock.contributionSession.findFirst.mockResolvedValue({
|
||
id: 'session-1',
|
||
treeId: 'tree-1',
|
||
status: 'pending',
|
||
changes: [],
|
||
})
|
||
prismaMock.contributionSession.update.mockResolvedValue({
|
||
id: 'session-1',
|
||
treeId: 'tree-1',
|
||
status: 'approved',
|
||
})
|
||
prismaMock.contributionSession.count.mockResolvedValue(0)
|
||
prismaMock.contributionChange.create.mockResolvedValue({ id: 'change-1' })
|
||
prismaMock.contributionChange.update.mockResolvedValue({ id: 'change-1' })
|
||
prismaMock.auditLog.create.mockResolvedValue({ id: 'audit-1' })
|
||
prismaMock.auditLog.findMany.mockResolvedValue([])
|
||
prismaMock.$transaction.mockImplementation(async (callback: (tx: typeof prismaMock) => Promise<unknown>) => callback(prismaMock))
|
||
prismaMock.$disconnect.mockResolvedValue(undefined)
|
||
})
|
||
|
||
describe('API security and authz', () => {
|
||
it('rate-limits login endpoint', async () => {
|
||
const app = createApp()
|
||
|
||
for (let i = 0; i < 10; i += 1) {
|
||
await app.inject({
|
||
method: 'POST',
|
||
url: '/auth/login',
|
||
payload: { email: 'user@example.com', password: 'password123' },
|
||
})
|
||
}
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/auth/login',
|
||
payload: { email: 'user@example.com', password: 'password123' },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(429)
|
||
await app.close()
|
||
// Onze connexions, chacune avec une vérification bcrypt : plus de 5 s sur les machines lentes de la CI
|
||
}, 20_000)
|
||
|
||
it('rate-limits register endpoint', async () => {
|
||
const app = createApp()
|
||
prismaMock.user.findUnique.mockResolvedValue({ id: 'existing-user' })
|
||
|
||
for (let i = 0; i < 20; i += 1) {
|
||
await app.inject({
|
||
method: 'POST',
|
||
url: '/auth/register',
|
||
payload: { email: `user${i}@example.com`, password: 'password123' },
|
||
})
|
||
}
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/auth/register',
|
||
payload: { email: 'overflow@example.com', password: 'password123' },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(429)
|
||
await app.close()
|
||
}, 15000)
|
||
|
||
it('revokes user token on logout', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
|
||
const logoutResponse = await app.inject({
|
||
method: 'POST',
|
||
url: '/auth/logout',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
})
|
||
|
||
expect(logoutResponse.statusCode).toBe(200)
|
||
|
||
const treesResponse = await app.inject({
|
||
method: 'GET',
|
||
url: '/trees',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
})
|
||
|
||
expect(treesResponse.statusCode).toBe(401)
|
||
await app.close()
|
||
})
|
||
|
||
it('rejects stale tree access tokens after access secret changes', async () => {
|
||
const app = createApp()
|
||
await app.ready()
|
||
|
||
prismaMock.treeAccessPasswords.findUnique.mockResolvedValue({
|
||
treeId: 'tree-1',
|
||
visitorHash: 'hash',
|
||
contributorHash: 'hash',
|
||
updatedAt: new Date('2026-02-12T10:00:00.000Z'),
|
||
tree: { deletedAt: null },
|
||
})
|
||
|
||
const staleTreeAccessToken = (app as any).jwt.sign({
|
||
kind: 'tree_access',
|
||
sub: 'tree:tree-1:visitor',
|
||
treeId: 'tree-1',
|
||
role: 'visitor',
|
||
accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(),
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'GET',
|
||
url: '/trees/tree-1/graph',
|
||
headers: { authorization: `Bearer ${staleTreeAccessToken}` },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(401)
|
||
await app.close()
|
||
})
|
||
|
||
it('does not update soft-deleted tree metadata', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.tree.updateMany.mockResolvedValue({ count: 0 })
|
||
|
||
const response = await app.inject({
|
||
method: 'PATCH',
|
||
url: '/trees/tree-1',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { name: 'Updated Name' },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(404)
|
||
await app.close()
|
||
})
|
||
|
||
it('validates rootPersonId belongs to tree before patching tree metadata', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.person.findFirst.mockResolvedValue(null)
|
||
|
||
const response = await app.inject({
|
||
method: 'PATCH',
|
||
url: '/trees/tree-1',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { rootPersonId: 'missing-person' },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(400)
|
||
await app.close()
|
||
})
|
||
|
||
it('allows owner to create persons, unions and links', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
|
||
const personResponse = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/persons',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { firstName: 'Alice', lastName: 'Martin' },
|
||
})
|
||
|
||
const unionResponse = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/unions',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { partner1PersonId: 'person-1', partner2PersonId: 'person-2' },
|
||
})
|
||
|
||
const linkResponse = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/links',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { parentPersonId: 'person-1', childPersonId: 'person-2', viaUnionId: 'union-1' },
|
||
})
|
||
|
||
expect(personResponse.statusCode).toBe(201)
|
||
expect(unionResponse.statusCode).toBe(201)
|
||
expect(linkResponse.statusCode).toBe(201)
|
||
await app.close()
|
||
})
|
||
|
||
it('forbids member from creating persons', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.treeMembership.findUnique.mockResolvedValue({
|
||
treeId: 'tree-1',
|
||
userId: 'user-1',
|
||
role: 'member',
|
||
tree: { deletedAt: null, rootPersonId: null },
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/persons',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { firstName: 'Alice', lastName: 'Martin' },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(403)
|
||
await app.close()
|
||
})
|
||
|
||
it('allows owner to patch and soft-delete a person', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
|
||
const patchResponse = await app.inject({
|
||
method: 'PATCH',
|
||
url: '/trees/tree-1/persons/person-1',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: {
|
||
firstName: 'Alice',
|
||
notes: 'Mise a jour',
|
||
},
|
||
})
|
||
|
||
expect(patchResponse.statusCode).toBe(200)
|
||
expect(prismaMock.person.updateMany).toHaveBeenCalled()
|
||
|
||
prismaMock.union.updateMany.mockResolvedValueOnce({ count: 2 })
|
||
prismaMock.parentChildLink.updateMany.mockResolvedValueOnce({ count: 3 })
|
||
prismaMock.mediaItem.updateMany.mockResolvedValueOnce({ count: 1 })
|
||
|
||
const deleteResponse = await app.inject({
|
||
method: 'DELETE',
|
||
url: '/trees/tree-1/persons/person-1',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
})
|
||
|
||
expect(deleteResponse.statusCode).toBe(200)
|
||
expect(deleteResponse.json()).toEqual({
|
||
ok: true,
|
||
cascaded: {
|
||
unions: 2,
|
||
links: 3,
|
||
medias: 1,
|
||
},
|
||
})
|
||
await app.close()
|
||
})
|
||
|
||
it('allows owner to patch union and link entities', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
|
||
const patchUnionResponse = await app.inject({
|
||
method: 'PATCH',
|
||
url: '/trees/tree-1/unions/union-1',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: {
|
||
unionType: 'mariage',
|
||
},
|
||
})
|
||
|
||
expect(patchUnionResponse.statusCode).toBe(200)
|
||
expect(prismaMock.union.updateMany).toHaveBeenCalled()
|
||
|
||
const patchLinkResponse = await app.inject({
|
||
method: 'PATCH',
|
||
url: '/trees/tree-1/links/link-1',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: {
|
||
parentageType: 'adoption',
|
||
},
|
||
})
|
||
|
||
expect(patchLinkResponse.statusCode).toBe(200)
|
||
expect(prismaMock.parentChildLink.updateMany).toHaveBeenCalled()
|
||
await app.close()
|
||
})
|
||
|
||
describe('PUT /trees/:id/links/order', () => {
|
||
const siblingLinks = [
|
||
{ id: 'link-a1', parentPersonId: 'father', childPersonId: 'anne', viaUnionId: 'union-1' },
|
||
{ id: 'link-a2', parentPersonId: 'mother', childPersonId: 'anne', viaUnionId: 'union-1' },
|
||
{ id: 'link-h1', parentPersonId: 'father', childPersonId: 'hugo', viaUnionId: 'union-1' },
|
||
{ id: 'link-h2', parentPersonId: 'mother', childPersonId: 'hugo', viaUnionId: 'union-1' },
|
||
]
|
||
const order = [
|
||
{ childId: 'hugo', linkIds: ['link-h1', 'link-h2'] },
|
||
{ childId: 'anne', linkIds: ['link-a1', 'link-a2'] },
|
||
]
|
||
|
||
it('rewrites the order of a sibling group in one transaction', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.parentChildLink.findMany.mockResolvedValue(siblingLinks)
|
||
prismaMock.$transaction.mockResolvedValue([])
|
||
|
||
const response = await app.inject({
|
||
method: 'PUT',
|
||
url: '/trees/tree-1/links/order',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { order },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(200)
|
||
expect(prismaMock.$transaction).toHaveBeenCalledTimes(1)
|
||
expect(prismaMock.parentChildLink.updateMany).toHaveBeenNthCalledWith(1, {
|
||
where: {
|
||
treeId: 'tree-1', deletedAt: null, childPersonId: 'hugo',
|
||
OR: [{ id: { in: ['link-h1', 'link-h2'] } }, { viaUnionId: { in: ['union-1'] } }],
|
||
},
|
||
data: { displayOrder: 1 },
|
||
})
|
||
expect(prismaMock.parentChildLink.updateMany).toHaveBeenNthCalledWith(2, {
|
||
where: {
|
||
treeId: 'tree-1', deletedAt: null, childPersonId: 'anne',
|
||
OR: [{ id: { in: ['link-a1', 'link-a2'] } }, { viaUnionId: { in: ['union-1'] } }],
|
||
},
|
||
data: { displayOrder: 2 },
|
||
})
|
||
expect(prismaMock.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
|
||
data: expect.objectContaining({ action: 'children_reordered', payloadJson: { childIds: ['hugo', 'anne'] } }),
|
||
}))
|
||
await app.close()
|
||
})
|
||
|
||
it('forbids a member who is not admin', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.treeMembership.findUnique.mockResolvedValue({
|
||
treeId: 'tree-1',
|
||
userId: 'user-1',
|
||
role: 'member',
|
||
tree: { deletedAt: null, rootPersonId: null },
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'PUT',
|
||
url: '/trees/tree-1/links/order',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { order },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(403)
|
||
expect(prismaMock.$transaction).not.toHaveBeenCalled()
|
||
await app.close()
|
||
})
|
||
|
||
it('refuses links that do not belong to the tree', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.parentChildLink.findMany.mockResolvedValue(siblingLinks.slice(0, 3))
|
||
|
||
const response = await app.inject({
|
||
method: 'PUT',
|
||
url: '/trees/tree-1/links/order',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { order },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(404)
|
||
expect(prismaMock.$transaction).not.toHaveBeenCalled()
|
||
await app.close()
|
||
})
|
||
|
||
it('refuses a link sent for the wrong child', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.parentChildLink.findMany.mockResolvedValue([siblingLinks[0], siblingLinks[2]])
|
||
|
||
const response = await app.inject({
|
||
method: 'PUT',
|
||
url: '/trees/tree-1/links/order',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { order: [{ childId: 'hugo', linkIds: ['link-a1'] }, { childId: 'anne', linkIds: ['link-h1'] }] },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(400)
|
||
expect(response.json()).toEqual({ error: 'invalid_link_reference' })
|
||
await app.close()
|
||
})
|
||
|
||
it('refuses children who share no parent', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.parentChildLink.findMany.mockResolvedValue([
|
||
{ id: 'link-x', parentPersonId: 'father', childPersonId: 'anne' },
|
||
{ id: 'link-y', parentPersonId: 'stranger', childPersonId: 'hugo' },
|
||
])
|
||
|
||
const response = await app.inject({
|
||
method: 'PUT',
|
||
url: '/trees/tree-1/links/order',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { order: [{ childId: 'anne', linkIds: ['link-x'] }, { childId: 'hugo', linkIds: ['link-y'] }] },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(400)
|
||
expect(response.json()).toEqual({ error: 'not_siblings' })
|
||
await app.close()
|
||
})
|
||
})
|
||
|
||
it('forbids member from patching person records', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.treeMembership.findUnique.mockResolvedValue({
|
||
treeId: 'tree-1',
|
||
userId: 'user-1',
|
||
role: 'member',
|
||
tree: { deletedAt: null, rootPersonId: null },
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'PATCH',
|
||
url: '/trees/tree-1/persons/person-1',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: {
|
||
firstName: 'Alice',
|
||
},
|
||
})
|
||
|
||
expect(response.statusCode).toBe(403)
|
||
await app.close()
|
||
})
|
||
|
||
it('allows contributor access token to submit a pending contribution session', async () => {
|
||
const app = createApp()
|
||
await app.ready()
|
||
|
||
const contributorToken = (app as any).jwt.sign({
|
||
kind: 'tree_access',
|
||
sub: 'tree:tree-1:contributor',
|
||
treeId: 'tree-1',
|
||
role: 'contributor',
|
||
accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(),
|
||
})
|
||
|
||
prismaMock.tree.findFirst.mockResolvedValue({
|
||
id: 'tree-1',
|
||
rootPersonId: null,
|
||
settings: {
|
||
contributorPolicy: 'pending',
|
||
},
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/contributions/sessions',
|
||
headers: { authorization: `Bearer ${contributorToken}` },
|
||
payload: {
|
||
title: 'Ajout photo branche maternelle',
|
||
changes: [
|
||
{
|
||
entityType: 'person',
|
||
action: 'create',
|
||
after: { firstName: 'Alice', lastName: 'Martin' },
|
||
},
|
||
],
|
||
},
|
||
})
|
||
|
||
expect(response.statusCode).toBe(201)
|
||
expect(prismaMock.contributionSession.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
data: expect.objectContaining({
|
||
status: 'pending',
|
||
mode: 'tree_access',
|
||
}),
|
||
}),
|
||
)
|
||
await app.close()
|
||
})
|
||
|
||
// Mot de passe de partage unique (25/09/2026)
|
||
it('opens as contributor with the old visitor password', async () => {
|
||
const { hashPassword } = await import('../src/lib/auth')
|
||
const app = createApp()
|
||
await app.ready()
|
||
|
||
prismaMock.treeAccessPasswords.findUnique.mockResolvedValue({
|
||
treeId: 'tree-1',
|
||
visitorHash: await hashPassword('regarder-seulement'),
|
||
contributorHash: await hashPassword('ajouter-aussi'),
|
||
updatedAt: new Date('2026-02-11T10:00:00.000Z'),
|
||
tree: { deletedAt: null },
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/access/unlock',
|
||
payload: { password: 'regarder-seulement' },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(200)
|
||
expect(response.json().role).toBe('contributor')
|
||
const payload = (app as any).jwt.decode(response.json().token)
|
||
expect(payload.role).toBe('contributor')
|
||
await app.close()
|
||
})
|
||
|
||
it('accepts the share password typed with a phone apostrophe and a trailing space', async () => {
|
||
const { hashPassword } = await import('../src/lib/auth')
|
||
const app = createApp()
|
||
await app.ready()
|
||
|
||
const hash = await hashPassword("Lavenuedel'Avenir")
|
||
prismaMock.treeAccessPasswords.findUnique.mockResolvedValue({
|
||
treeId: 'tree-1',
|
||
visitorHash: hash,
|
||
contributorHash: hash,
|
||
updatedAt: new Date('2026-02-11T10:00:00.000Z'),
|
||
tree: { deletedAt: null },
|
||
})
|
||
|
||
for (const typed of ['Lavenuedel’Avenir', "Lavenuedel'Avenir ", 'Lavenuedel’Avenir ']) {
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/access/unlock',
|
||
payload: { password: typed },
|
||
})
|
||
expect(response.statusCode, JSON.stringify(typed)).toBe(200)
|
||
}
|
||
|
||
const wrong = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/access/unlock',
|
||
payload: { password: 'lavenuedel’avenir' },
|
||
})
|
||
expect(wrong.statusCode).toBe(401)
|
||
await app.close()
|
||
}, 30000) // une dizaine de vérifications bcrypt, lentes sur la machine de CI
|
||
|
||
it('still rejects a wrong share password', async () => {
|
||
const { hashPassword } = await import('../src/lib/auth')
|
||
const app = createApp()
|
||
await app.ready()
|
||
|
||
prismaMock.treeAccessPasswords.findUnique.mockResolvedValue({
|
||
treeId: 'tree-1',
|
||
visitorHash: await hashPassword('regarder-seulement'),
|
||
contributorHash: await hashPassword('ajouter-aussi'),
|
||
updatedAt: new Date('2026-02-11T10:00:00.000Z'),
|
||
tree: { deletedAt: null },
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/access/unlock',
|
||
payload: { password: 'pas-le-bon' },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(401)
|
||
await app.close()
|
||
})
|
||
|
||
it('lets an older visitor access token submit a contribution', async () => {
|
||
const app = createApp()
|
||
await app.ready()
|
||
|
||
const visitorToken = (app as any).jwt.sign({
|
||
kind: 'tree_access',
|
||
sub: 'tree:tree-1:visitor',
|
||
treeId: 'tree-1',
|
||
role: 'visitor',
|
||
accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(),
|
||
})
|
||
|
||
prismaMock.tree.findFirst.mockResolvedValue({
|
||
id: 'tree-1',
|
||
rootPersonId: null,
|
||
settings: { contributorPolicy: 'pending' },
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/contributions/sessions',
|
||
headers: { authorization: `Bearer ${visitorToken}` },
|
||
payload: {
|
||
title: 'Ajout depuis un ancien accès visiteur',
|
||
changes: [
|
||
{ entityType: 'person', action: 'create', after: { firstName: 'Alice', lastName: 'Martin' } },
|
||
],
|
||
},
|
||
})
|
||
|
||
expect(response.statusCode).toBe(201)
|
||
await app.close()
|
||
})
|
||
|
||
it('writes the single share password into both hashes', async () => {
|
||
const { verifyPassword } = await import('../src/lib/auth')
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
|
||
const response = await app.inject({
|
||
method: 'PATCH',
|
||
url: '/trees/tree-1/access/passwords',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { password: 'un-seul-mot-de-passe' },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(200)
|
||
const data = prismaMock.treeAccessPasswords.update.mock.calls.at(-1)?.[0]?.data
|
||
expect(data.visitorHash).toBe(data.contributorHash)
|
||
expect(await verifyPassword('un-seul-mot-de-passe', data.contributorHash)).toBe(true)
|
||
await app.close()
|
||
})
|
||
|
||
// Mot de passe toujours à jour chez le propriétaire (25/09/2026) : gardé chiffré par le serveur
|
||
it('keeps the new share password readable for the owner', async () => {
|
||
const { decryptSharePassword } = await import('../src/lib/share-password')
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
|
||
await app.inject({
|
||
method: 'PATCH',
|
||
url: '/trees/tree-1/access/passwords',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { password: 'lilas-en-avril' },
|
||
})
|
||
|
||
const stored = prismaMock.treeSharePassword.upsert.mock.calls.at(-1)?.[0]?.create?.passwordEnc
|
||
expect(stored).not.toContain('lilas-en-avril')
|
||
expect(decryptSharePassword(stored)).toBe('lilas-en-avril')
|
||
|
||
prismaMock.treeSharePassword.findUnique.mockResolvedValue({ treeId: 'tree-1', passwordEnc: stored })
|
||
const read = await app.inject({
|
||
method: 'GET',
|
||
url: '/trees/tree-1/access/passwords',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
})
|
||
expect(read.statusCode).toBe(200)
|
||
expect(read.json().share).toBe('lilas-en-avril')
|
||
await app.close()
|
||
})
|
||
|
||
it('forgets the readable password when two different passwords are set', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
|
||
await app.inject({
|
||
method: 'PATCH',
|
||
url: '/trees/tree-1/access/passwords',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { visitorPassword: 'regarder-seul' },
|
||
})
|
||
|
||
expect(prismaMock.treeSharePassword.deleteMany).toHaveBeenCalledWith({ where: { treeId: 'tree-1' } })
|
||
expect(prismaMock.treeSharePassword.upsert).not.toHaveBeenCalled()
|
||
await app.close()
|
||
})
|
||
|
||
it('remembers the current password without changing access', async () => {
|
||
const { hashPassword } = await import('../src/lib/auth')
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.treeAccessPasswords.findUnique.mockResolvedValue({
|
||
treeId: 'tree-1',
|
||
visitorHash: await hashPassword('ancien-visiteur'),
|
||
contributorHash: await hashPassword('celui-de-la-famille'),
|
||
updatedAt: new Date('2026-02-11T10:00:00.000Z'),
|
||
tree: { deletedAt: null },
|
||
})
|
||
|
||
const wrong = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/access/passwords/remember',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { password: 'pas-le-bon' },
|
||
})
|
||
expect(wrong.statusCode).toBe(422)
|
||
expect(prismaMock.treeSharePassword.upsert).not.toHaveBeenCalled()
|
||
|
||
const right = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/access/passwords/remember',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { password: 'celui-de-la-famille' },
|
||
})
|
||
expect(right.statusCode).toBe(200)
|
||
expect(right.json().share).toBe('celui-de-la-famille')
|
||
expect(prismaMock.treeSharePassword.upsert).toHaveBeenCalledTimes(1)
|
||
expect(prismaMock.treeAccessPasswords.update).not.toHaveBeenCalled()
|
||
await app.close()
|
||
})
|
||
|
||
it('refuses the readable password to anyone but an admin', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.treeMembership.findUnique.mockResolvedValue(null)
|
||
|
||
const read = await app.inject({
|
||
method: 'GET',
|
||
url: '/trees/tree-1/access/passwords',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
})
|
||
expect(read.statusCode).toBe(403)
|
||
|
||
const anonymous = await app.inject({ method: 'GET', url: '/trees/tree-1/access/passwords' })
|
||
expect(anonymous.statusCode).toBe(401)
|
||
await app.close()
|
||
})
|
||
|
||
// Contributions fiables (25/09/2026) : mot d'accompagnement, liens d'une personne ajoutée
|
||
const signShareToken = (app: any) => app.jwt.sign({
|
||
kind: 'tree_access',
|
||
sub: 'tree:tree-1:contributor',
|
||
treeId: 'tree-1',
|
||
role: 'contributor',
|
||
accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(),
|
||
})
|
||
|
||
it('stores the contributor first name and comment', async () => {
|
||
const app = createApp()
|
||
await app.ready()
|
||
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null, settings: { contributorPolicy: 'pending' } })
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/contributions/sessions',
|
||
headers: { authorization: `Bearer ${signShareToken(app)}` },
|
||
payload: {
|
||
title: 'Contribution de Camille',
|
||
submittedByLabel: 'Camille',
|
||
comment: "J'ai ajouté mon cousin",
|
||
changes: [{ entityType: 'person', action: 'create', after: { firstName: 'Léon', lastName: 'Martin' } }],
|
||
},
|
||
})
|
||
|
||
expect(response.statusCode).toBe(201)
|
||
expect(prismaMock.contributionSession.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({ data: expect.objectContaining({ submittedByLabel: 'Camille', comment: "J'ai ajouté mon cousin" }) }),
|
||
)
|
||
await app.close()
|
||
})
|
||
|
||
it('links a person added in the same contribution, even when the link comes first', async () => {
|
||
const app = createApp()
|
||
await app.ready()
|
||
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null, settings: { contributorPolicy: 'direct' } })
|
||
prismaMock.person.create.mockResolvedValue({ id: 'person-new' })
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/contributions/sessions',
|
||
headers: { authorization: `Bearer ${signShareToken(app)}` },
|
||
payload: {
|
||
title: 'Un enfant pour Alice',
|
||
changes: [
|
||
{ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: 'person-1', childPersonId: 'tmp:léon', parentageType: 'biologique' } },
|
||
{ entityType: 'person', action: 'create', after: { ref: 'tmp:léon', firstName: 'Léon', lastName: 'Martin' } },
|
||
],
|
||
},
|
||
})
|
||
|
||
expect(response.statusCode).toBe(201)
|
||
expect(prismaMock.parentChildLink.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({ data: expect.objectContaining({ parentPersonId: 'person-1', childPersonId: 'person-new' }) }),
|
||
)
|
||
expect(prismaMock.$transaction).toHaveBeenCalled()
|
||
await app.close()
|
||
})
|
||
|
||
it('drops the link of a rejected new person instead of failing the review', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.contributionSession.findFirst.mockResolvedValue({
|
||
id: 'session-1',
|
||
treeId: 'tree-1',
|
||
status: 'pending',
|
||
changes: [
|
||
{ id: 'change-link', entityType: 'parent_child_link', action: 'create', entityId: null, beforeJson: null, afterJson: { parentPersonId: 'person-1', childPersonId: 'tmp:léon' }, conflictState: 'none' },
|
||
{ id: 'change-person', entityType: 'person', action: 'create', entityId: null, beforeJson: null, afterJson: { ref: 'tmp:léon', firstName: 'Léon', lastName: 'Martin' }, conflictState: 'none' },
|
||
],
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/contributions/sessions/session-1/review',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: {
|
||
decision: 'approved',
|
||
changeDecisions: [{ changeId: 'change-person', decision: 'rejected' }],
|
||
},
|
||
})
|
||
|
||
expect(response.statusCode).toBe(200)
|
||
expect(prismaMock.parentChildLink.create).not.toHaveBeenCalled()
|
||
expect(prismaMock.contributionChange.update).toHaveBeenCalledWith(
|
||
expect.objectContaining({ where: { id: 'change-link' }, data: { decision: 'rejected' } }),
|
||
)
|
||
await app.close()
|
||
})
|
||
|
||
it('refuses a link to an unknown provisional person at submission', async () => {
|
||
const app = createApp()
|
||
await app.ready()
|
||
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null, settings: { contributorPolicy: 'direct' } })
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/contributions/sessions',
|
||
headers: { authorization: `Bearer ${signShareToken(app)}` },
|
||
payload: {
|
||
title: 'Lien orphelin',
|
||
changes: [{ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: 'person-1', childPersonId: 'tmp:personne' } }],
|
||
},
|
||
})
|
||
|
||
expect(response.statusCode).toBe(400)
|
||
expect(response.json().error).toBe('unresolved_reference')
|
||
await app.close()
|
||
})
|
||
|
||
it('allows admin to list pending contribution sessions', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.contributionSession.findMany.mockResolvedValue([
|
||
{
|
||
id: 'session-1',
|
||
treeId: 'tree-1',
|
||
status: 'pending',
|
||
title: 'Session pending',
|
||
changes: [],
|
||
},
|
||
])
|
||
|
||
const response = await app.inject({
|
||
method: 'GET',
|
||
url: '/trees/tree-1/contributions/sessions?status=pending',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(200)
|
||
expect(response.json()).toEqual({
|
||
sessions: [
|
||
{
|
||
id: 'session-1',
|
||
treeId: 'tree-1',
|
||
status: 'pending',
|
||
title: 'Session pending',
|
||
changes: [],
|
||
},
|
||
],
|
||
})
|
||
await app.close()
|
||
})
|
||
|
||
it('forbids non-admin user from listing contribution sessions', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.treeMembership.findUnique.mockResolvedValue({
|
||
treeId: 'tree-1',
|
||
userId: 'user-1',
|
||
role: 'member',
|
||
tree: { deletedAt: null, rootPersonId: null },
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'GET',
|
||
url: '/trees/tree-1/contributions/sessions',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(403)
|
||
await app.close()
|
||
})
|
||
|
||
it('allows admin to review a contribution session', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
|
||
prismaMock.contributionSession.findFirst.mockResolvedValue({
|
||
id: 'session-1',
|
||
treeId: 'tree-1',
|
||
status: 'pending',
|
||
changes: [
|
||
{
|
||
id: 'change-1',
|
||
entityType: 'person',
|
||
action: 'create',
|
||
entityId: null,
|
||
beforeJson: null,
|
||
afterJson: { firstName: 'Alice', lastName: 'Martin' },
|
||
conflictState: 'none',
|
||
},
|
||
],
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/contributions/sessions/session-1/review',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: {
|
||
decision: 'approved',
|
||
},
|
||
})
|
||
|
||
expect(response.statusCode).toBe(200)
|
||
expect(prismaMock.contributionSession.update).toHaveBeenCalled()
|
||
expect(prismaMock.contributionChange.update).toHaveBeenCalled()
|
||
await app.close()
|
||
})
|
||
|
||
it('« à vérifier » proposé par la famille : appliqué à l’approbation, en création comme en modification', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
|
||
prismaMock.contributionSession.findFirst.mockResolvedValue({
|
||
id: 'session-1',
|
||
treeId: 'tree-1',
|
||
status: 'pending',
|
||
changes: [
|
||
{ id: 'change-1', entityType: 'person', action: 'create', entityId: null, beforeJson: null, afterJson: { firstName: 'Alice', lastName: 'Martin', toVerify: true }, conflictState: 'none' },
|
||
{ id: 'change-2', entityType: 'person', action: 'update', entityId: 'person-1', beforeJson: { toVerify: false }, afterJson: { toVerify: true }, conflictState: 'none' },
|
||
],
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/contributions/sessions/session-1/review',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { decision: 'approved' },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(200)
|
||
expect(prismaMock.person.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ firstName: 'Alice', toVerify: true }) }))
|
||
expect(prismaMock.person.updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'person-1' }), data: expect.objectContaining({ toVerify: true }) }))
|
||
await app.close()
|
||
})
|
||
|
||
it('allows owner to upload media below image limit', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/persons/person-1/media',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: {
|
||
type: 'photo',
|
||
fileName: 'portrait.jpg',
|
||
mimeType: 'image/jpeg',
|
||
sizeBytes: PNG_1PX.length,
|
||
dataBase64: PNG_1PX.toString('base64'),
|
||
caption: 'Portrait famille',
|
||
},
|
||
})
|
||
|
||
expect(response.statusCode).toBe(201)
|
||
await app.close()
|
||
})
|
||
|
||
it('rejects image upload above 5MB', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
|
||
const tooBig = Buffer.alloc((5 * 1024 * 1024) + 1, 1)
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/persons/person-1/media',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: {
|
||
type: 'photo',
|
||
fileName: 'big.jpg',
|
||
mimeType: 'image/jpeg',
|
||
sizeBytes: tooBig.length,
|
||
dataBase64: tooBig.toString('base64'),
|
||
},
|
||
})
|
||
|
||
expect(response.statusCode).toBe(400)
|
||
await app.close()
|
||
})
|
||
|
||
// Souvenirs de la famille (25/09/2026) : un non-administrateur propose, le propriétaire valide
|
||
const photoPayload = {
|
||
type: 'photo',
|
||
fileName: 'portrait.jpg',
|
||
mimeType: 'image/jpeg',
|
||
sizeBytes: PNG_1PX.length,
|
||
dataBase64: PNG_1PX.toString('base64'),
|
||
}
|
||
|
||
it('turns a member media upload into a pending contribution', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.treeMembership.findUnique.mockResolvedValue({
|
||
treeId: 'tree-1',
|
||
userId: 'user-1',
|
||
role: 'member',
|
||
contributionModeOverride: null,
|
||
tree: { deletedAt: null, rootPersonId: null, settings: { memberContributionPolicy: 'pending' } },
|
||
})
|
||
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', firstName: 'Jeanne' })
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/persons/person-1/media',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: photoPayload,
|
||
})
|
||
|
||
expect(response.statusCode).toBe(201)
|
||
expect(prismaMock.mediaItem.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({ data: expect.objectContaining({ status: 'pending', displayOrder: 0, isFeatured: false }) }),
|
||
)
|
||
await app.close()
|
||
})
|
||
|
||
it('lets a share link visitor propose a photo, pending review', async () => {
|
||
const app = createApp()
|
||
await app.ready()
|
||
const shareToken = (app as any).jwt.sign({
|
||
kind: 'tree_access',
|
||
sub: 'tree:tree-1:visitor',
|
||
treeId: 'tree-1',
|
||
role: 'visitor',
|
||
accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(),
|
||
})
|
||
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null, settings: { contributorPolicy: 'pending' } })
|
||
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', firstName: 'Jeanne' })
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/persons/person-1/media',
|
||
headers: { authorization: `Bearer ${shareToken}` },
|
||
payload: { ...photoPayload, submittedByLabel: 'Camille' },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(201)
|
||
expect(prismaMock.contributionSession.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
data: expect.objectContaining({
|
||
status: 'pending',
|
||
mode: 'tree_access',
|
||
submittedByLabel: 'Camille',
|
||
title: 'Souvenir pour Jeanne',
|
||
}),
|
||
}),
|
||
)
|
||
expect(prismaMock.mediaItem.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
data: expect.objectContaining({ status: 'pending', contributionSessionId: 'session-1', uploadedBy: null }),
|
||
}),
|
||
)
|
||
expect(prismaMock.contributionChange.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
data: expect.objectContaining({ entityType: 'media', action: 'create', sessionId: 'session-1' }),
|
||
}),
|
||
)
|
||
await app.close()
|
||
})
|
||
|
||
it('shows a family photo at once when the tree applies contributions directly', async () => {
|
||
const app = createApp()
|
||
await app.ready()
|
||
const shareToken = (app as any).jwt.sign({
|
||
kind: 'tree_access',
|
||
sub: 'tree:tree-1:contributor',
|
||
treeId: 'tree-1',
|
||
role: 'contributor',
|
||
accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(),
|
||
})
|
||
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null, settings: { contributorPolicy: 'direct' } })
|
||
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', firstName: 'Jeanne' })
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/persons/person-1/media',
|
||
headers: { authorization: `Bearer ${shareToken}` },
|
||
payload: photoPayload,
|
||
})
|
||
|
||
expect(response.statusCode).toBe(201)
|
||
expect(prismaMock.mediaItem.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({ data: expect.objectContaining({ status: 'approved' }) }),
|
||
)
|
||
expect(prismaMock.contributionChange.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({ data: expect.objectContaining({ entityType: 'media', decision: 'approved' }) }),
|
||
)
|
||
await app.close()
|
||
})
|
||
|
||
it('refuses a media upload from a share token of another tree', async () => {
|
||
const app = createApp()
|
||
await app.ready()
|
||
const otherTreeToken = (app as any).jwt.sign({
|
||
kind: 'tree_access',
|
||
sub: 'tree:tree-2:contributor',
|
||
treeId: 'tree-2',
|
||
role: 'contributor',
|
||
accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(),
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/persons/person-1/media',
|
||
headers: { authorization: `Bearer ${otherTreeToken}` },
|
||
payload: photoPayload,
|
||
})
|
||
|
||
expect(response.statusCode).toBeGreaterThanOrEqual(401)
|
||
expect(response.statusCode).toBeLessThanOrEqual(403)
|
||
expect(prismaMock.mediaItem.create).not.toHaveBeenCalled()
|
||
await app.close()
|
||
})
|
||
|
||
it('rejects media changes submitted through the contribution session route', async () => {
|
||
const app = createApp()
|
||
await app.ready()
|
||
const shareToken = (app as any).jwt.sign({
|
||
kind: 'tree_access',
|
||
sub: 'tree:tree-1:contributor',
|
||
treeId: 'tree-1',
|
||
role: 'contributor',
|
||
accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(),
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/contributions/sessions',
|
||
headers: { authorization: `Bearer ${shareToken}` },
|
||
payload: { title: 'Tentative', changes: [{ entityType: 'media', action: 'create', entityId: 'media-9' }] },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(400)
|
||
await app.close()
|
||
})
|
||
|
||
it('makes an approved family photo visible at review', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.contributionSession.findFirst.mockResolvedValue({
|
||
id: 'session-1',
|
||
treeId: 'tree-1',
|
||
status: 'pending',
|
||
changes: [
|
||
{ id: 'change-1', entityType: 'media', action: 'create', entityId: 'media-1', beforeJson: null, afterJson: { personId: 'person-1' }, conflictState: 'none' },
|
||
],
|
||
})
|
||
prismaMock.mediaItem.findFirst.mockResolvedValue({ id: 'media-1', personId: 'person-1' })
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/contributions/sessions/session-1/review',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { decision: 'approved' },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(200)
|
||
expect(prismaMock.mediaItem.update).toHaveBeenCalledWith(
|
||
expect.objectContaining({ where: { id: 'media-1' }, data: expect.objectContaining({ status: 'approved' }) }),
|
||
)
|
||
await app.close()
|
||
})
|
||
|
||
it('hides a rejected family photo at review', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.contributionSession.findFirst.mockResolvedValue({
|
||
id: 'session-1',
|
||
treeId: 'tree-1',
|
||
status: 'pending',
|
||
changes: [
|
||
{ id: 'change-1', entityType: 'media', action: 'create', entityId: 'media-1', beforeJson: null, afterJson: { personId: 'person-1' }, conflictState: 'none' },
|
||
],
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/contributions/sessions/session-1/review',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { decision: 'rejected' },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(200)
|
||
expect(prismaMock.mediaItem.updateMany).toHaveBeenCalledWith(
|
||
expect.objectContaining({ where: { id: 'media-1', treeId: 'tree-1' }, data: expect.objectContaining({ status: 'rejected' }) }),
|
||
)
|
||
await app.close()
|
||
})
|
||
|
||
it('un moment proposé par la famille devient visible si accepté, disparaît si refusé', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
const session = (status = 'pending') => ({
|
||
id: 'session-1',
|
||
treeId: 'tree-1',
|
||
status,
|
||
changes: [
|
||
{ id: 'change-1', entityType: 'life_event', action: 'create', entityId: 'event-1', beforeJson: null, afterJson: { personId: 'person-1', title: 'Départ pour Paris' }, conflictState: 'none' },
|
||
],
|
||
})
|
||
const review = (decision: string) => app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/contributions/sessions/session-1/review',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { decision },
|
||
})
|
||
|
||
prismaMock.contributionSession.findFirst.mockResolvedValue(session())
|
||
prismaMock.lifeEvent.updateMany.mockResolvedValue({ count: 1 })
|
||
expect((await review('approved')).statusCode).toBe(200)
|
||
expect(prismaMock.lifeEvent.updateMany).toHaveBeenCalledWith({ where: { id: 'event-1', treeId: 'tree-1', deletedAt: null }, data: { status: 'approved' } })
|
||
|
||
prismaMock.contributionSession.findFirst.mockResolvedValue(session())
|
||
expect((await review('rejected')).statusCode).toBe(200)
|
||
expect(prismaMock.lifeEvent.updateMany).toHaveBeenCalledWith({ where: { id: 'event-1', treeId: 'tree-1' }, data: expect.objectContaining({ status: 'rejected' }) })
|
||
await app.close()
|
||
})
|
||
|
||
it('reviews a session containing a drawing without failing', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.contributionSession.findFirst.mockResolvedValue({
|
||
id: 'session-1',
|
||
treeId: 'tree-1',
|
||
status: 'pending',
|
||
changes: [
|
||
{ id: 'change-1', entityType: 'annotation', action: 'create', entityId: 'annot-1', beforeJson: null, afterJson: { type: 'drawing', x: 0, y: 0 }, conflictState: 'none' },
|
||
],
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/contributions/sessions/session-1/review',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { decision: 'approved' },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(200)
|
||
await app.close()
|
||
})
|
||
|
||
it('allows owner to upload citation without file payload', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/persons/person-1/media',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: {
|
||
type: 'citation',
|
||
caption: 'Le passe guide l avenir',
|
||
source: 'Journal familial',
|
||
},
|
||
})
|
||
|
||
expect(response.statusCode).toBe(201)
|
||
expect(prismaMock.mediaItem.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
data: expect.objectContaining({
|
||
type: 'citation',
|
||
mimeType: 'text/plain',
|
||
caption: 'Le passe guide l avenir',
|
||
source: 'Journal familial',
|
||
}),
|
||
}),
|
||
)
|
||
await app.close()
|
||
})
|
||
|
||
it('allows owner to upload geojson media', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
const geojsonText = JSON.stringify({
|
||
type: 'FeatureCollection',
|
||
features: [
|
||
{
|
||
type: 'Feature',
|
||
geometry: { type: 'Point', coordinates: [2.35, 48.85] },
|
||
properties: { label: 'Paris' },
|
||
},
|
||
],
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/persons/person-1/media',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: {
|
||
type: 'geojson',
|
||
fileName: 'migration.geojson',
|
||
mimeType: 'application/geo+json',
|
||
sizeBytes: Buffer.byteLength(geojsonText),
|
||
dataBase64: Buffer.from(geojsonText).toString('base64'),
|
||
caption: 'Migration',
|
||
},
|
||
})
|
||
|
||
expect(response.statusCode).toBe(201)
|
||
await app.close()
|
||
})
|
||
|
||
it('rejects a gpx upload whose content is not a gpx track', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: '/trees/tree-1/persons/person-1/media',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: {
|
||
type: 'gpx',
|
||
fileName: 'trace.gpx',
|
||
mimeType: 'application/gpx+xml',
|
||
sizeBytes: PNG_1PX.length,
|
||
dataBase64: PNG_1PX.toString('base64'),
|
||
},
|
||
})
|
||
|
||
expect(response.statusCode).toBe(400)
|
||
expect(response.json().error).toBe('invalid_media_mime_type')
|
||
await app.close()
|
||
})
|
||
|
||
it('allows owner to read tree stats', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
|
||
prismaMock.mediaItem.count.mockResolvedValue(3)
|
||
prismaMock.contributionSession.count
|
||
.mockResolvedValueOnce(4)
|
||
.mockResolvedValueOnce(9)
|
||
|
||
const response = await app.inject({
|
||
method: 'GET',
|
||
url: '/trees/tree-1/stats',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(200)
|
||
expect(response.json()).toEqual({
|
||
stats: {
|
||
persons: 2,
|
||
unions: 1,
|
||
filiations: 1,
|
||
medias: 3,
|
||
pendingContributions: 4,
|
||
totalContributions: 9,
|
||
},
|
||
})
|
||
await app.close()
|
||
})
|
||
|
||
it('forbids non-admin from reading tree stats', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.treeMembership.findUnique.mockResolvedValue({
|
||
treeId: 'tree-1',
|
||
userId: 'user-1',
|
||
role: 'member',
|
||
tree: { deletedAt: null, rootPersonId: null },
|
||
})
|
||
|
||
const response = await app.inject({
|
||
method: 'GET',
|
||
url: '/trees/tree-1/stats',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(403)
|
||
await app.close()
|
||
})
|
||
|
||
it('allows owner to update tree settings policy', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
|
||
const response = await app.inject({
|
||
method: 'PATCH',
|
||
url: '/trees/tree-1/settings',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: {
|
||
memberContributionPolicy: 'direct',
|
||
},
|
||
})
|
||
|
||
expect(response.statusCode).toBe(200)
|
||
expect(prismaMock.treeSettings.upsert).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
where: { treeId: 'tree-1' },
|
||
update: expect.objectContaining({
|
||
memberContributionPolicy: 'direct',
|
||
}),
|
||
}),
|
||
)
|
||
expect(prismaMock.auditLog.create).toHaveBeenCalled()
|
||
await app.close()
|
||
})
|
||
|
||
it('lists audit logs for admin with pagination cursor', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
prismaMock.auditLog.findMany.mockResolvedValue([
|
||
{
|
||
id: 'audit-2',
|
||
treeId: 'tree-1',
|
||
actorType: 'user',
|
||
actorId: 'user-1',
|
||
action: 'tree_settings_updated',
|
||
entityType: 'tree_settings',
|
||
entityId: 'tree-1',
|
||
payloadJson: { memberContributionPolicy: 'direct' },
|
||
createdAt: new Date('2026-02-11T12:00:00.000Z'),
|
||
},
|
||
])
|
||
|
||
const response = await app.inject({
|
||
method: 'GET',
|
||
url: '/trees/tree-1/audit?limit=20&offset=0',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(200)
|
||
expect(response.json()).toEqual({
|
||
logs: [
|
||
{
|
||
id: 'audit-2',
|
||
treeId: 'tree-1',
|
||
actorType: 'user',
|
||
actorId: 'user-1',
|
||
action: 'tree_settings_updated',
|
||
entityType: 'tree_settings',
|
||
entityId: 'tree-1',
|
||
payloadJson: { memberContributionPolicy: 'direct' },
|
||
createdAt: '2026-02-11T12:00:00.000Z',
|
||
},
|
||
],
|
||
nextOffset: null,
|
||
})
|
||
await app.close()
|
||
})
|
||
|
||
// Audit 1.0 (30/09/2026)
|
||
it('refuses the annotations of a tree the account has no access to', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app, 'stranger')
|
||
prismaMock.treeMembership.findUnique.mockResolvedValue(null)
|
||
prismaMock.userTreeAccess.findUnique.mockResolvedValue(null)
|
||
|
||
const response = await app.inject({
|
||
method: 'GET',
|
||
url: '/trees/tree-1/annotations',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(403)
|
||
expect(prismaMock.annotation.findMany).not.toHaveBeenCalled()
|
||
await app.close()
|
||
})
|
||
|
||
it('no longer accepts an account session in a media address', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
|
||
const response = await app.inject({
|
||
method: 'GET',
|
||
url: `/trees/tree-1/media/media-1?token=${encodeURIComponent(token)}`,
|
||
})
|
||
|
||
expect(response.statusCode).toBe(401)
|
||
await app.close()
|
||
})
|
||
|
||
it('cuts accounts linked by the share password when the owner changes it', async () => {
|
||
const app = createApp()
|
||
const token = await createUserToken(app)
|
||
|
||
const response = await app.inject({
|
||
method: 'PATCH',
|
||
url: '/trees/tree-1/access/passwords',
|
||
headers: { authorization: `Bearer ${token}` },
|
||
payload: { password: 'nouveau-mot-de-passe' },
|
||
})
|
||
|
||
expect(response.statusCode).toBe(200)
|
||
expect(prismaMock.userTreeAccess.deleteMany).toHaveBeenCalledWith({ where: { treeId: 'tree-1' } })
|
||
await app.close()
|
||
})
|
||
|
||
it('masks tokens in logged addresses', async () => {
|
||
const { redactUrl } = await import('../src/app.js')
|
||
expect(redactUrl('/trees/t/media/m?token=abc.def&v=2')).toBe('/trees/t/media/m?token=[masqué]&v=2')
|
||
})
|
||
})
|