teach/test/images.test.js
Ramon 0d644ba41d
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 27s
feat: voeg afbeeldingscatalogus en opslagquota toe
2026-07-15 23:45:17 +02:00

168 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 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('<svg onload="alert(1)"></svg>')), 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 themas 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\)/);
});