All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 27s
168 lines
8.8 KiB
JavaScript
168 lines
8.8 KiB
JavaScript
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 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\)/);
|
||
});
|