diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts
index fdec471..8a97597 100644
--- a/backend/test/api-routes.test.ts
+++ b/backend/test/api-routes.test.ts
@@ -35,7 +35,7 @@ const { prismaMock } = vi.hoisted(() => ({
findMany: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
- updateMany: vi.fn(),
+ updateMany: vi.fn(), deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
person: {
findMany: vi.fn(),
@@ -59,7 +59,7 @@ const { prismaMock } = vi.hoisted(() => ({
updateMany: vi.fn(),
},
mediaItem: {
- count: vi.fn(),
+ count: vi.fn(), aggregate: vi.fn().mockResolvedValue({ _sum: { sizeBytes: 0 } }),
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
@@ -73,7 +73,7 @@ const { prismaMock } = vi.hoisted(() => ({
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
- update: vi.fn(),
+ update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }),
count: vi.fn(),
},
contributionChange: {
@@ -128,6 +128,9 @@ beforeAll(async () => {
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' })
diff --git a/backend/test/api-security.integration.test.ts b/backend/test/api-security.integration.test.ts
index ebd5f0b..9538097 100644
--- a/backend/test/api-security.integration.test.ts
+++ b/backend/test/api-security.integration.test.ts
@@ -40,7 +40,7 @@ const { prismaMock } = vi.hoisted(() => ({
findMany: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
- updateMany: vi.fn(),
+ updateMany: vi.fn(), deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
person: {
findMany: vi.fn(),
@@ -64,7 +64,7 @@ const { prismaMock } = vi.hoisted(() => ({
updateMany: vi.fn(),
},
mediaItem: {
- count: vi.fn(),
+ count: vi.fn(), aggregate: vi.fn().mockResolvedValue({ _sum: { sizeBytes: 0 } }),
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
@@ -78,7 +78,7 @@ const { prismaMock } = vi.hoisted(() => ({
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
- update: vi.fn(),
+ update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }),
count: vi.fn(),
},
contributionChange: {
@@ -100,6 +100,9 @@ vi.mock('../src/lib/prisma.js', () => ({
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'
@@ -134,6 +137,9 @@ beforeAll(async () => {
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' })
@@ -1021,8 +1027,8 @@ describe('API security and authz', () => {
type: 'photo',
fileName: 'portrait.jpg',
mimeType: 'image/jpeg',
- sizeBytes: 5,
- dataBase64: Buffer.from('hello').toString('base64'),
+ sizeBytes: PNG_1PX.length,
+ dataBase64: PNG_1PX.toString('base64'),
caption: 'Portrait famille',
},
})
@@ -1059,8 +1065,8 @@ describe('API security and authz', () => {
type: 'photo',
fileName: 'portrait.jpg',
mimeType: 'image/jpeg',
- sizeBytes: 5,
- dataBase64: Buffer.from('hello').toString('base64'),
+ sizeBytes: PNG_1PX.length,
+ dataBase64: PNG_1PX.toString('base64'),
}
it('turns a member media upload into a pending contribution', async () => {
@@ -1346,10 +1352,9 @@ describe('API security and authz', () => {
await app.close()
})
- it('rejects mismatched mime type for gpx media', async () => {
+ it('rejects a gpx upload whose content is not a gpx track', async () => {
const app = createApp()
const token = await createUserToken(app)
- const fakePayload = ''
const response = await app.inject({
method: 'POST',
@@ -1358,9 +1363,9 @@ describe('API security and authz', () => {
payload: {
type: 'gpx',
fileName: 'trace.gpx',
- mimeType: 'image/png',
- sizeBytes: Buffer.byteLength(fakePayload),
- dataBase64: Buffer.from(fakePayload).toString('base64'),
+ mimeType: 'application/gpx+xml',
+ sizeBytes: PNG_1PX.length,
+ dataBase64: PNG_1PX.toString('base64'),
},
})
@@ -1486,4 +1491,56 @@ describe('API security and authz', () => {
})
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')
+ })
})
diff --git a/backend/test/e2e-journeys.test.ts b/backend/test/e2e-journeys.test.ts
index 3d35d77..dea0160 100644
--- a/backend/test/e2e-journeys.test.ts
+++ b/backend/test/e2e-journeys.test.ts
@@ -45,7 +45,7 @@ const { prismaMock } = vi.hoisted(() => ({
findMany: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
- updateMany: vi.fn(),
+ updateMany: vi.fn(), deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
person: {
findMany: vi.fn(),
@@ -69,7 +69,7 @@ const { prismaMock } = vi.hoisted(() => ({
updateMany: vi.fn(),
},
mediaItem: {
- count: vi.fn(),
+ count: vi.fn(), aggregate: vi.fn().mockResolvedValue({ _sum: { sizeBytes: 0 } }),
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
@@ -83,7 +83,7 @@ const { prismaMock } = vi.hoisted(() => ({
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
- update: vi.fn(),
+ update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }),
count: vi.fn(),
},
contributionChange: {
@@ -123,6 +123,9 @@ beforeAll(async () => {
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: 'owner@example.com' })
diff --git a/backend/test/security-hardening.test.ts b/backend/test/security-hardening.test.ts
new file mode 100644
index 0000000..09a1d7d
--- /dev/null
+++ b/backend/test/security-hardening.test.ts
@@ -0,0 +1,124 @@
+import sharp from 'sharp'
+import { describe, expect, it, vi } from 'vitest'
+
+import { sanitizeAnnotationContentForRead, sanitizeAnnotationContentForWrite } from '../src/lib/annotation-content.js'
+import { isMimeAllowedForType, sanitizeImage, scrubMp4Location, sniffMimeType } from '../src/lib/media-files.js'
+import { matchSharePassword } from '../src/lib/password-variants.js'
+import { isTreeUnlockLocked, recordTreeUnlockFailure, resetTreeUnlockGuard } from '../src/lib/unlock-guard.js'
+
+const PNG_1PX = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==', 'base64')
+
+function accepted(buf: Buffer, type: Parameters[1], fileName = ''): string | null {
+ const mime = sniffMimeType(buf, type, fileName)
+ return mime && isMimeAllowedForType(type, mime) ? mime : null
+}
+
+describe('fichiers envoyés : type lu dans les octets', () => {
+ it('refuse un SVG envoyé comme photo', () => {
+ const svg = Buffer.from('')
+ expect(accepted(svg, 'photo', 'chat.svg')).toBeNull()
+ })
+
+ it('refuse du HTML déguisé en PNG', () => {
+ expect(accepted(Buffer.from(''), 'photo', 'photo.png')).toBeNull()
+ })
+
+ it('reconnaît une vraie photo PNG quel que soit le nom', () => {
+ expect(accepted(PNG_1PX, 'photo', 'photo.jpg')).toBe('image/png')
+ })
+
+ it('accepte un tracé GPX et refuse un document XHTML envoyé comme GPX', () => {
+ expect(accepted(Buffer.from('\n'), 'gpx')).toBe('application/gpx+xml')
+ expect(accepted(Buffer.from(''), 'gpx')).toBeNull()
+ })
+
+ it('refuse un document texte qui commence par une balise', () => {
+ expect(accepted(Buffer.from(''), 'document', 'note.txt')).toBeNull()
+ expect(accepted(Buffer.from('Souvenir de la ferme'), 'document', 'note.txt')).toBe('text/plain')
+ })
+
+ it('refuse une image envoyée comme GeoJSON', () => {
+ expect(accepted(PNG_1PX, 'geojson')).toBeNull()
+ expect(accepted(Buffer.from('{"type":"FeatureCollection","features":[]}'), 'geojson')).toBe('application/geo+json')
+ })
+})
+
+describe('photos : métadonnées retirées, rotation appliquée', () => {
+ it('retire le GPS et tourne les pixels selon l orientation du téléphone', async () => {
+ const withGps = await sharp({ create: { width: 64, height: 48, channels: 3, background: { r: 200, g: 120, b: 60 } } })
+ .jpeg()
+ .withMetadata({ orientation: 6 })
+ .withExifMerge({ IFD3: { GPSLatitudeRef: 'N', GPSLatitude: '50/1 37/1 5/1' } })
+ .toBuffer()
+ expect((await sharp(withGps).metadata()).exif).toBeDefined()
+
+ const cleaned = await sanitizeImage(withGps, 'image/jpeg')
+ const meta = await sharp(cleaned).metadata()
+ expect(meta.exif).toBeUndefined()
+ expect(meta.width).toBe(48)
+ expect(meta.height).toBe(64)
+ })
+
+ it('refuse une image illisible', async () => {
+ await expect(sanitizeImage(Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x01]), 'image/jpeg')).rejects.toThrow('invalid_image')
+ })
+})
+
+describe('vidéos : position ISO 6709 remise à zéro dans moov seulement', () => {
+ function box(type: string, payload: Buffer): Buffer {
+ const header = Buffer.alloc(8)
+ header.writeUInt32BE(8 + payload.length, 0)
+ header.write(type, 4, 'latin1')
+ return Buffer.concat([header, payload])
+ }
+
+ it('efface les coordonnées de moov sans toucher à mdat', () => {
+ const ftyp = box('ftyp', Buffer.from('isom0000'))
+ const mdat = box('mdat', Buffer.from('+50.6300+003.0600/ donnees'))
+ const moov = box('moov', Buffer.from('udta xyz +50.6300+003.0600+025.000/ fin'))
+ const scrubbed = scrubMp4Location(Buffer.concat([ftyp, mdat, moov])).toString('latin1')
+ expect(scrubbed).toContain('+00.0000+000.0000+000.000/ fin')
+ expect(scrubbed).toContain('+50.6300+003.0600/ donnees')
+ })
+})
+
+describe('annotations photo : jamais d adresse enregistrée', () => {
+ const treeId = 'tree-1'
+
+ it('retire l adresse (et la session qu elle portait) à l écriture', () => {
+ const content = JSON.stringify({ photoPath: `/trees/${treeId}/annotation-photos/abc-123`, url: '/api/x?token=SESSION', w: 1, h: 1 })
+ const stored = sanitizeAnnotationContentForWrite(content, treeId)
+ expect(stored).not.toContain('token')
+ expect(JSON.parse(stored)).toEqual({ photoPath: `/trees/${treeId}/annotation-photos/abc-123`, w: 1, h: 1 })
+ })
+
+ it('refuse un chemin de photo d un autre arbre ou une adresse externe', () => {
+ expect(() => sanitizeAnnotationContentForWrite(JSON.stringify({ photoPath: '/trees/autre/annotation-photos/abc' }), treeId)).toThrow()
+ expect(() => sanitizeAnnotationContentForWrite(JSON.stringify({ url: 'https://pisteur.example/pixel.gif' }), treeId)).toThrow()
+ })
+
+ it('nettoie les anciennes valeurs à la lecture et laisse le reste intact', () => {
+ expect(sanitizeAnnotationContentForRead(JSON.stringify({ photoPath: '/p', url: '/x?token=S' }))).toBe(JSON.stringify({ photoPath: '/p' }))
+ expect(sanitizeAnnotationContentForRead('Mamie à 20 ans')).toBe('Mamie à 20 ans')
+ expect(sanitizeAnnotationContentForRead(JSON.stringify({ paths: [] }))).toBe(JSON.stringify({ paths: [] }))
+ })
+})
+
+describe('mot de passe de partage', () => {
+ it('ne compare qu une fois quand les deux colonnes portent le même hash', async () => {
+ const verify = vi.fn(async () => false)
+ await matchSharePassword('abcdefgh', ['h1', 'h1'], verify)
+ expect(verify).toHaveBeenCalledTimes(1)
+ })
+
+ it('verrouille un arbre après 30 échecs dans la fenêtre, puis le libère', () => {
+ resetTreeUnlockGuard()
+ const now = Date.now()
+ for (let i = 0; i < 29; i += 1) recordTreeUnlockFailure('tree-x', now)
+ expect(isTreeUnlockLocked('tree-x', now)).toBe(false)
+ recordTreeUnlockFailure('tree-x', now)
+ expect(isTreeUnlockLocked('tree-x', now)).toBe(true)
+ expect(isTreeUnlockLocked('tree-autre', now)).toBe(false)
+ expect(isTreeUnlockLocked('tree-x', now + 16 * 60 * 1000)).toBe(false)
+ })
+})