teach/test/save-system.test.js
Ramon 6980d72041 v0.3.18-beta: bewaar en herstel eerdere versies van borddata
- Elke data-write bewaart eerst de oude data in user_data_versions (hooguit
  één snapshot per 10 minuten, maximaal 10 versies per gebruiker)
- Nieuwe routes: GET /me/data/versions (lijst) en POST /me/data/restore
  (zet een versie terug; bewaart de huidige data eerst zodat herstel zelf
  ook omkeerbaar is); beide niet voor leerlingen
- Nieuw blok "Reservekopieën" in Instellingen → Account met datum/tijd per
  versie en een herstelknop met bevestigingsstap (arm-then-confirm)
- Vier nieuwe servertests (snapshot vóór write, opruimen op 10, pupil-403,
  restore bewaart huidige data eerst)
2026-07-14 16:23:10 +02:00

146 lines
6.4 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import rateLimit from '@fastify/rate-limit';
import api from '../src/api.js';
// Ingelogde leerkracht via een gemockte sessie: elke query die de sessie-join
// doet levert de gebruiker, de data-update legt zijn SQL+params vast zodat de
// test de rev-voorwaarde en het 409-pad kan controleren.
function makeApp({ dataRev = 3, updateSucceeds = true } = {}) {
const calls = [];
const pool = {
async query(sql, params = []) {
calls.push({ sql, params });
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [{
id: 7, username: 'docent', display_name: 'Docent', role: 'teacher',
school_id: 2, class_id: null, data: {}, data_rev: dataRev,
}] };
if (sql.includes('FROM user_roles')) return { rows: [] };
if (sql.startsWith('SELECT data, data_rev')) return { rows: [{ data: { boards: {} }, data_rev: dataRev }] };
if (sql.startsWith('UPDATE users SET data =')) {
return updateSucceeds ? { rows: [{ data_rev: dataRev + 1 }] } : { rows: [] };
}
return { rows: [] };
},
};
return (async () => {
const app = Fastify({ trustProxy: 2 });
await app.register(cookie);
await app.register(rateLimit, { global: false });
app.decorate('pg', pool);
await app.register(api, { prefix: '/api' });
await app.ready();
return { app, calls };
})();
}
const sessionCookie = { teach_session: 'x'.repeat(64) };
test('GET /me/data geeft data én rev terug', async () => {
const { app } = await makeApp({ dataRev: 3 });
const res = await app.inject({ method: 'GET', url: '/api/me/data', cookies: sessionCookie });
assert.equal(res.statusCode, 200, res.body);
assert.deepEqual(res.json(), { data: { boards: {} }, rev: 3 });
await app.close();
});
test('PUT /me/data met actuele rev slaagt en geeft de nieuwe rev', async () => {
const { app, calls } = await makeApp({ dataRev: 3 });
const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie,
headers: { 'x-data-rev': '3' }, payload: { boards: {} } });
assert.equal(res.statusCode, 200, res.body);
assert.equal(res.json().rev, 4);
const upd = calls.find((c) => c.sql.startsWith('UPDATE users SET data ='));
assert.ok(upd.sql.includes('AND data_rev = $3'));
assert.equal(upd.params[2], 3);
await app.close();
});
test('PUT /me/data met verouderde rev geeft 409', async () => {
const { app } = await makeApp({ dataRev: 5, updateSucceeds: false });
const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie,
headers: { 'x-data-rev': '3' }, payload: { boards: {} } });
assert.equal(res.statusCode, 409, res.body);
assert.match(res.json().error, /ander tabblad/);
await app.close();
});
test('PUT /me/data zonder rev-header slaat zonder voorwaarde op (legacy)', async () => {
const { app, calls } = await makeApp({ dataRev: 3 });
const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie,
payload: { boards: {} } });
assert.equal(res.statusCode, 200, res.body);
const upd = calls.find((c) => c.sql.startsWith('UPDATE users SET data ='));
assert.ok(!upd.sql.includes('AND data_rev'));
await app.close();
});
test('PUT /me/data maakt eerst een snapshot en ruimt oude versies op', async () => {
const { app, calls } = await makeApp({ dataRev: 3 });
const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie,
headers: { 'x-data-rev': '3' }, payload: { boards: {} } });
assert.equal(res.statusCode, 200, res.body);
const snapIdx = calls.findIndex((c) => c.sql.includes('INSERT INTO user_data_versions'));
const cleanIdx = calls.findIndex((c) => c.sql.includes('DELETE FROM user_data_versions'));
const updIdx = calls.findIndex((c) => c.sql.startsWith('UPDATE users SET data ='));
assert.ok(snapIdx >= 0 && cleanIdx >= 0);
assert.ok(snapIdx < updIdx, 'snapshot vóór de overschrijving');
assert.equal(calls[cleanIdx].params[1], 10);
await app.close();
});
test('GET /me/data/versions weigert leerlingen', async () => {
const { app } = await makeApp({ dataRev: 3 });
// aparte app met pupil-rol
const pupilApp = await (async () => {
const calls = [];
const pool = { async query(sql, params = []) {
calls.push({ sql, params });
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [{
id: 8, username: 'kind', role: 'pupil', school_id: 2, class_id: 5, data: {}, data_rev: 0,
}] };
return { rows: [] };
} };
const a = Fastify({ trustProxy: 2 });
await a.register(cookie);
await a.register(rateLimit, { global: false });
a.decorate('pg', pool);
await a.register(api, { prefix: '/api' });
await a.ready();
return a;
})();
const res = await pupilApp.inject({ method: 'GET', url: '/api/me/data/versions', cookies: sessionCookie });
assert.equal(res.statusCode, 403);
await pupilApp.close();
await app.close();
});
test('POST /me/data/restore zet een versie terug en bewaart de huidige eerst', async () => {
const calls = [];
const pool = { async query(sql, params = []) {
calls.push({ sql, params });
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [{
id: 7, username: 'docent', role: 'teacher', school_id: 2, class_id: null, data: { boards: { f: 1 } }, data_rev: 6,
}] };
if (sql.includes('FROM user_roles')) return { rows: [] };
if (sql.startsWith('SELECT data FROM user_data_versions')) return { rows: [{ data: { boards: { f: 0 } } }] };
if (sql.startsWith('UPDATE users SET data =')) return { rows: [{ data_rev: 7 }] };
return { rows: [] };
} };
const app = Fastify({ trustProxy: 2 });
await app.register(cookie);
await app.register(rateLimit, { global: false });
app.decorate('pg', pool);
await app.register(api, { prefix: '/api' });
await app.ready();
const res = await app.inject({ method: 'POST', url: '/api/me/data/restore', cookies: sessionCookie,
payload: { versionId: 12 } });
assert.equal(res.statusCode, 200, res.body);
assert.deepEqual(res.json(), { data: { boards: { f: 0 } }, rev: 7 });
const snapIdx = calls.findIndex((c) => c.sql.includes('INSERT INTO user_data_versions'));
const updIdx = calls.findIndex((c) => c.sql.startsWith('UPDATE users SET data ='));
assert.ok(snapIdx >= 0 && snapIdx < updIdx, 'huidige data eerst bewaard');
await app.close();
});