import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import Fastify from 'fastify'; import imageApi, { DEFAULT_IMAGE_QUOTAS, canManageImage, canSeeImage, defaultImageQuota, detectImageType, imageScopeForUser, normaliseImageFolder, } from '../src/images.js'; const superUser = { id: 1, role: 'super', school_id: null, allRoles: ['super'] }; const admin = { id: 2, role: 'admin', school_id: 7, allRoles: ['admin'] }; const teacher = { id: 3, role: 'teacher', school_id: 7, allRoles: ['teacher'] }; const parent = { id: 4, role: 'parent', school_id: 7, allRoles: ['parent'] }; const pupil = { id: 5, role: 'pupil', school_id: 7, allRoles: ['pupil'] }; test('afbeeldingsquota en uploadscope volgen alle rollen, inclusief ouders', () => { assert.equal(defaultImageQuota(superUser), null); assert.equal(defaultImageQuota(admin), 400 * 1024 * 1024); for (const user of [teacher, parent, pupil]) assert.equal(defaultImageQuota(user), 200 * 1024 * 1024); assert.equal(DEFAULT_IMAGE_QUOTAS.default, 200 * 1024 * 1024); assert.equal(imageScopeForUser(superUser), 'global'); assert.equal(imageScopeForUser(admin), 'school'); assert.equal(imageScopeForUser(teacher), 'personal'); assert.equal(imageScopeForUser(parent), 'personal'); assert.equal(imageScopeForUser(pupil), 'personal'); }); test('verwijderen volgt systeem-, school- en persoonlijk eigenaarschap', () => { const global = { scope: 'global', owner_id: 1, school_id: null }; const school = { scope: 'school', owner_id: 8, school_id: 7 }; const personal = { scope: 'personal', owner_id: teacher.id, school_id: 7 }; for (const owner of [teacher, parent, pupil]) assert.equal(canManageImage(owner, { ...personal, owner_id: owner.id }), true); assert.equal(canManageImage(superUser, global), true); assert.equal(canManageImage(admin, global), false); assert.equal(canManageImage(admin, school), true); assert.equal(canManageImage(teacher, school), false); assert.equal(canManageImage(teacher, personal), true); assert.equal(canManageImage(parent, personal), false); assert.equal(canSeeImage(teacher, global), true); assert.equal(canSeeImage(teacher, school), true); assert.equal(canSeeImage(parent, personal), false); }); test('alleen echte veilige rasterafbeeldingen en geldige map-paden worden geaccepteerd', () => { const png = Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a,0,0,0,0]); const jpg = Buffer.from([0xff,0xd8,0xff,0xe0,0,0,0,0,0,0,0,0]); const webp = Buffer.from('RIFF1234WEBP'); assert.deepEqual(detectImageType(png), { mime: 'image/png', ext: 'png' }); assert.deepEqual(detectImageType(jpg), { mime: 'image/jpeg', ext: 'jpg' }); assert.deepEqual(detectImageType(webp), { mime: 'image/webp', ext: 'webp' }); assert.equal(detectImageType(Buffer.from('')), null); assert.equal(normaliseImageFolder('Rekenen/Geld/Munten'), 'Rekenen/Geld/Munten'); assert.equal(normaliseImageFolder('../privé'), null); assert.equal(normaliseImageFolder('dubbel//pad'), null); assert.equal(normaliseImageFolder(''), ''); }); async function appFor(user, pool) { const storageDir = await mkdtemp(join(tmpdir(), 'teach-images-')); const app = Fastify(); app.decorate('pg', pool); await app.register(imageApi, { prefix: '/api', storageDir, resolveUser: async () => user }); await app.ready(); return { app, storageDir }; } test('persoonlijke upload kan alleen door eigenaar of systeemmanager worden verwijderd', async () => { const calls = []; const item = { id: 9, scope: 'personal', owner_id: teacher.id, school_id: 7, storage_key: '00000000-0000-4000-8000-000000000000.png', public_path: null }; const pool = { async query(sql, params=[]) { calls.push({sql,params}); if (sql.includes('SELECT * FROM image_assets')) return { rows: [item] }; return { rows: [] }; }}; const { app, storageDir } = await appFor(teacher, pool); const own = await app.inject({ method: 'DELETE', url: '/api/images/9' }); assert.equal(own.statusCode, 200, own.body); assert.ok(calls.some(call => call.sql.includes('DELETE FROM image_assets'))); await app.close();await rm(storageDir,{recursive:true,force:true}); const otherPool = { async query(sql) { if (sql.includes('SELECT * FROM image_assets')) return { rows: [{...item,owner_id:99}] }; return { rows: [] }; }}; const other = await appFor(teacher, otherPool); const denied = await other.app.inject({ method: 'DELETE', url: '/api/images/9' }); assert.equal(denied.statusCode, 404); await other.app.close();await rm(other.storageDir,{recursive:true,force:true}); }); test('schoolbeheerder uploadt voor school en de persoonlijke 200MB-grens wordt atomair bewaakt', async () => { const png = Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a,0,0,0,0]); const calls = []; const client = { async query(sql, params=[]) { calls.push({sql,params}); if (sql.includes('SELECT id, role, school_id, image_quota_bytes FROM users')) return { rows: [{id:admin.id,role:'admin',school_id:7,image_quota_bytes:null}] }; if (sql.includes('sum(size_bytes)') && sql.includes('owner_id')) return { rows: [{used:0}] }; if (sql.includes('SELECT id, image_quota_bytes FROM schools')) return { rows: [{id:7,image_quota_bytes:null}] }; if (sql.includes('sum(size_bytes)') && sql.includes('school_id')) return { rows: [{used:0}] }; if (sql.includes('SELECT * FROM image_themes')) return { rows: [ {id:11,scope:'global',school_id:null,owner_id:1}, {id:12,scope:'school',school_id:7,owner_id:admin.id}, ] }; if (sql.includes('INSERT INTO image_assets')) return { rows: [{ id:44,scope:'school',school_id:7,owner_id:admin.id,folder:'',name:'Test', mime_type:'image/png',size_bytes:png.length,storage_key:params[9],public_path:null, }] }; return { rows: [] }; }, release() {}, }; const pool = { connect: async()=>client, query: (...args)=>client.query(...args) }; const { app, storageDir } = await appFor(admin,pool); const uploaded = await app.inject({method:'POST',url:'/api/images?name=Test&themes=11,12', headers:{'content-type':'image/png'},payload:png}); assert.equal(uploaded.statusCode,200,uploaded.body); const insert=calls.find(call=>call.sql.includes('INSERT INTO image_assets')); assert.equal(insert.params[1],'school'); assert.equal(insert.params[2],7); assert.equal(insert.params[3],admin.id); assert.equal(calls.filter(call=>call.sql.includes('INSERT INTO image_asset_themes')).length,2); await app.close();await rm(storageDir,{recursive:true,force:true}); const limitedCalls=[]; const limitedClient={ async query(sql){ limitedCalls.push(sql); if(sql.includes('SELECT id, role, school_id, image_quota_bytes FROM users')) return{rows:[{id:teacher.id,role:'teacher',school_id:null,image_quota_bytes:null}]}; if(sql.includes('sum(size_bytes)'))return{rows:[{used:200*1024*1024-4}]}; return{rows:[]}; },release(){} }; const limited=await appFor(teacher,{connect:async()=>limitedClient,query:(...args)=>limitedClient.query(...args)}); const over=await limited.app.inject({method:'POST',url:'/api/images?name=Teveel', headers:{'content-type':'image/png'},payload:png}); assert.equal(over.statusCode,413,over.body); assert.ok(!limitedCalls.some(sql=>sql.includes('INSERT INTO image_assets'))); await limited.app.close();await rm(limited.storageDir,{recursive:true,force:true}); }); test('migratie seedt geldafbeeldingen in meerdere thema’s en UI koppelt catalogus aan het bord', async () => { const [migration,server,index,board,catalog,css] = await Promise.all([ readFile('db/012_image_catalog.sql','utf8'),readFile('src/server.js','utf8'), readFile('public/index.html','utf8'),readFile('public/js/board.js','utf8'), readFile('public/js/image-catalog.js','utf8'),readFile('public/css/image-catalog.css','utf8'), ]); assert.equal((migration.split("/img/money/").length-1),12); for(const theme of ['builtin-money','builtin-coins','builtin-banknotes'])assert.match(migration,new RegExp(theme)); assert.match(migration,/CREATE TABLE IF NOT EXISTS image_asset_themes/); assert.match(migration,/ADD COLUMN IF NOT EXISTS image_quota_bytes/g); assert.match(server,/app\.register\(imageApi, \{ prefix: '\/api' \}\)/); assert.match(index,/css\/image-catalog\.css/); assert.ok(index.includes('js/image-catalog.js')); assert.match(index,/id="btnImages"/); assert.match(board,/openImageCatalog\(src=>addBoardImage/); assert.match(board,/uploadImageToCatalog\(f\)/); for(const feature of ['ic-search','ic-theme-filter','ic-folder-filter','openImageFolderExplorer','renderImageStoragePanel']) assert.ok(catalog.includes(feature),feature); assert.match(css,/@media\(max-width:760px\)/); });