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);