Files
WhatIDo/backup.selftest.js
dev 76d36e5c35 feat(backup): формат бэкапа v2 — audit/уведомления/баны, counts и одноразовый тикет
Валидация и нормализация restore вынесены из server.js в backup-restore.js,
покрыты юнит-тестами backup.selftest.js (30+ проверок, без стенда).

В бэкап добавлены audit_log, notifications, notification_reads, banned_ips,
счётчики counts, метаданные приложения и версия формата (принимаются 1..2).
Тикет бэкапа стал одноразовым: файл удаляется сразу после отдачи,
uploadBackup фильтрует расширения. sessions в бэкап не входит намеренно.

sweepOrphanedUploads учитывает аватары, обложки, оригиналы фото и photo_jobs —
иначе restore сносил файлы сразу после восстановления.
storage.getStream при STORAGE_LOCAL_FALLBACK читает локальный файл, а не S3.
2026-10-04 01:36:58 +03:00

88 lines
6.0 KiB
JavaScript

const {
BACKUP_FORMAT_VERSION,
BACKUP_TABLES,
BACKUP_SEQUENCE_TABLES,
isSupportedBackupVersion,
restoredCounts,
isSafeUploadPath,
normalizeRestoreData,
} = require('./backup-restore');
let failed = 0;
function ok(label, cond, extra) {
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${extra !== undefined && !cond ? ' -> ' + JSON.stringify(extra) : ''}`);
if (!cond) failed++;
}
function throws(label, fn) {
let threw = false;
try { fn(); } catch (e) { threw = true; }
ok(label, threw);
}
ok('формат 1 (старый архив) поддерживается', isSupportedBackupVersion({ version: 1, groups: [] }));
ok(`формат ${BACKUP_FORMAT_VERSION} поддерживается`, isSupportedBackupVersion({ version: BACKUP_FORMAT_VERSION, groups: [] }));
ok('формат 0 отклоняется', !isSupportedBackupVersion({ version: 0, groups: [] }));
ok('формат из будущего отклоняется', !isSupportedBackupVersion({ version: BACKUP_FORMAT_VERSION + 1, groups: [] }));
ok('без groups отклоняется', !isSupportedBackupVersion({ version: BACKUP_FORMAT_VERSION }));
ok('не объект отклоняется', !isSupportedBackupVersion(null));
ok('в бэкап входят audit_log/notifications/banned_ips',
['audit_log', 'notifications', 'notification_reads', 'banned_ips'].every(t => BACKUP_TABLES.includes(t)),
BACKUP_TABLES);
ok('sessions не попадают в бэкап', !BACKUP_TABLES.includes('sessions'));
ok('sequences сбрасываются для audit_log и notifications',
BACKUP_SEQUENCE_TABLES.includes('audit_log') && BACKUP_SEQUENCE_TABLES.includes('notifications'));
const counts = restoredCounts({ groups: [1, 2], settings: { a: '1', b: '2' }, audit_log: [1] });
ok('restoredCounts считает строки и settings', counts.groups === 2 && counts.settings === 2 && counts.audit_log === 1, counts);
ok('restoredCounts для отсутствующей таблицы = 0', restoredCounts({ groups: [] }).notifications === 0);
ok('безопасный путь загрузки принимается', isSafeUploadPath('/uploads/1700000000000-abc123.jpg'));
ok('path traversal отклоняется', !isSafeUploadPath('/uploads/../../etc/passwd'));
ok('вложенный путь отклоняется', !isSafeUploadPath('/uploads/.originals/x.jpg'));
ok('чужой префикс отклоняется', !isSafeUploadPath('/etc/passwd'));
const base = { version: BACKUP_FORMAT_VERSION, groups: [], entries: [], users: [], branches: [] };
const n = normalizeRestoreData({
...base,
groups: [{ id: 1, name: 'G', deleted_at: '2026-01-02T03:04:05.000Z', purge_at: '2026-02-03T04:05:06.000Z' }],
});
ok('groups.deleted_at больше не теряется', n.groups[0].deleted_at === '2026-01-02T03:04:05.000Z', n.groups[0]);
ok('groups.purge_at больше не теряется', n.groups[0].purge_at === '2026-02-03T04:05:06.000Z', n.groups[0]);
const e = normalizeRestoreData({
...base,
entries: [{ id: 1, student_name: 'S', group_id: 1, description: 'd', deleted_at: '2026-01-02T03:04:05.000Z', purge_at: '2026-03-04T05:06:07.000Z' }],
});
ok('entries.purge_at больше не теряется', e.entries[0].purge_at === '2026-03-04T05:06:07.000Z', e.entries[0]);
const nt = normalizeRestoreData({
...base,
notifications: [{ id: 5, type: 'entry.new', level: 'warning', title: 'T', body: 'B', link: 'l', target: { a: 1 }, admin_only: true, branch_id: 2 }],
notification_reads: [{ user_id: 1, notification_id: 5 }],
audit_log: [{ id: 7, user_id: 1, action: 'backup.download', target: { size: 5 }, ip: '1.2.3.4' }],
banned_ips: [{ ip: '203.0.113.7', reason: 'manual', banned_until: '2026-05-05T00:00:00.000Z' }],
});
ok('notifications нормализуются', nt.notifications[0].level === 'warning' && nt.notifications[0].title === 'T', nt.notifications[0]);
ok('notifications.target остаётся JSON-строкой', nt.notifications[0].target === '{"a":1}', nt.notifications[0].target);
ok('notification_reads нормализуются', nt.notification_reads[0].notification_id === 5);
ok('audit_log нормализуется', nt.audit_log[0].action === 'backup.download' && nt.audit_log[0].target === '{"size":5}', nt.audit_log[0]);
ok('banned_ips нормализуются', nt.banned_ips[0].ip === '203.0.113.7');
const badLevel = normalizeRestoreData({ ...base, notifications: [{ id: 1, type: 'x', level: 'drop-table', title: 'T' }] });
ok('неизвестный level уведомления -> info', badLevel.notifications[0].level === 'info', badLevel.notifications[0]);
throws('мусорный ip в banned_ips отклоняется', () => normalizeRestoreData({ ...base, banned_ips: [{ ip: 'не ip', reason: 'r', banned_until: '2026-01-01T00:00:00.000Z' }] }));
throws('пустой banned_until отклоняется', () => normalizeRestoreData({ ...base, banned_ips: [{ ip: '1.2.3.4', reason: 'r' }] }));
throws('пустой action в audit_log отклоняется', () => normalizeRestoreData({ ...base, audit_log: [{ id: 1, action: ' ' }] }));
throws('не-объект в notifications.target отклоняется', () => normalizeRestoreData({ ...base, notifications: [{ id: 1, type: 'x', title: 'T', target: [1, 2, 3] }] }));
throws('группа без id отклоняется', () => normalizeRestoreData({ ...base, groups: [{ name: 'G' }] }));
throws('путь вне uploads отклоняется', () => normalizeRestoreData({ ...base, students: [{ id: 1, name: 'S', photo_path: '/etc/passwd' }] }));
throws('notifications не массив отклоняется', () => normalizeRestoreData({ ...base, notifications: { nope: 1 } }));
const legacy = normalizeRestoreData({ version: 1, groups: [{ id: 1, name: 'G' }] });
ok('старый архив без новых таблиц восстанавливается', Array.isArray(legacy.audit_log) && legacy.audit_log.length === 0 && legacy.groups.length === 1, Object.keys(legacy));
console.log(failed ? `\n${failed} проверок провалено` : '\nBACKUP SELFTEST OK');
process.exit(failed ? 1 : 0);