mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-09 15:50:58 +00:00
Tests de l'audit 1.0 : fichiers, annotations, jetons, mot de passe de partage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5b06d020bd
commit
91c93ddfa1
4 files changed
+205
-18
No files matched your search
@@ -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' })
|
||||
|
||||
@@ -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 = '<gpx version=\"1.1\"></gpx>'
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
|
||||
@@ -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<typeof sniffMimeType>[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('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>')
|
||||
expect(accepted(svg, 'photo', 'chat.svg')).toBeNull()
|
||||
})
|
||||
|
||||
it('refuse du HTML déguisé en PNG', () => {
|
||||
expect(accepted(Buffer.from('<html><script>alert(1)</script></html>'), '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('<?xml version="1.0"?>\n<gpx version="1.1"></gpx>'), 'gpx')).toBe('application/gpx+xml')
|
||||
expect(accepted(Buffer.from('<html xmlns="http://www.w3.org/1999/xhtml"><script>1</script></html>'), 'gpx')).toBeNull()
|
||||
})
|
||||
|
||||
it('refuse un document texte qui commence par une balise', () => {
|
||||
expect(accepted(Buffer.from('<!doctype html><script>1</script>'), '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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user