'use strict'; /** * tests/run-tests.js — smoke/unit тесты без внешних зависимостей. * Запускать: node tests/run-tests.js * БД не нужна. */ let pass = 0, fail = 0; function test(name, actual, expected) { if (actual === expected) { console.log(' PASS ' + name); pass++; } else { console.error(' FAIL ' + name + ': got ' + JSON.stringify(actual) + ', want ' + JSON.stringify(expected)); fail++; } } function testThrows(name, fn) { try { fn(); console.error(' FAIL ' + name + ': expected throw, got no error'); fail++; } catch (e) { console.log(' PASS ' + name + ' (throws: ' + e.message + ')'); pass++; } } function section(title) { console.log('\n── ' + title + ' ──'); } // ──────────────────────────────────────────────────────────────── // 1. Модули загружаются без ошибок // ──────────────────────────────────────────────────────────────── section('Загрузка модулей'); const modules = [ 'src/config.js', 'src/auth.js', 'src/queries.js', 'src/validators.js', 'src/middleware/rateLimit.js', 'src/middleware/csrf.js', 'src/routes/export.js', 'src/routes/auth.js', 'src/routes/entries.js', 'src/routes/admin.js', 'server.js', ]; for (const m of modules) { try { require('../' + m); test('require ' + m, true, true); } catch (e) { test('require ' + m, false, true); } } // ──────────────────────────────────────────────────────────────── // 2. src/config.js // ──────────────────────────────────────────────────────────────── section('config.js'); const { MOCK_USERS, backUrl } = require('../src/config'); test('MOCK_USERS.length === 3', MOCK_USERS.length, 3); test('admin clientId', MOCK_USERS.some(u => u.clientId === 'WZ01112'), true); test('all have companyId', MOCK_USERS.every(u => !!u.companyId), true); test('admin backUrl has company', backUrl(true, 5, {}).includes('company=5'), true); test('user backUrl has no company', backUrl(false, 5, {}).includes('company='), false); test('extra propagated', backUrl(true, 5, { error: 'foo' }).includes('error=foo'), true); test('empty → /', backUrl(false, null, {}), '/'); // ──────────────────────────────────────────────────────────────── // 3. src/validators.js // ──────────────────────────────────────────────────────────────── section('validators.js'); const { aggregateCIDRs, validate, overlaps } = require('../src/validators'); // aggregateCIDRs test('single /32', aggregateCIDRs(['203.0.114.1/32']).join(), '203.0.114.1/32'); test('adjacent /32 → /31', aggregateCIDRs(['8.8.8.0/32', '8.8.8.1/32']).join(), '8.8.8.0/31'); test('subnet passthrough /24', aggregateCIDRs(['1.2.3.0/24']).join(), '1.2.3.0/24'); test('empty → []', aggregateCIDRs([]).length, 0); // validate — корректные входные данные try { const r = validate('8.8.8.8/32'); test('valid /32 cidr', r.cidr, '8.8.8.8/32'); test('valid /32 wasNormalized', r.wasNormalized, false); } catch (e) { test('valid /32', false, true); } try { const r = validate('8.8.8.8'); // без маски → /32 test('no-mask → /32', r.cidr, '8.8.8.8/32'); } catch (e) { test('no-mask → /32', false, true); } try { const r = validate('1.2.3.7/24'); // нормализация host-битов test('normalize host bits', r.cidr, '1.2.3.0/24'); test('wasNormalized=true', r.wasNormalized, true); } catch (e) { test('normalize host bits', false, true); } try { const r = validate('4.4.0.0/22'); // минимальная допустимая маска test('min mask /22', r.cidr, '4.4.0.0/22'); } catch (e) { test('min mask /22', false, true); } // validate — некорректные (должны бросать) testThrows('empty throws', () => validate('')); testThrows('private 10.x throws', () => validate('10.1.2.3/32')); testThrows('private 192.168 throws', () => validate('192.168.1.0/24')); testThrows('mask /8 throws', () => validate('1.2.3.4/8')); testThrows('mask /21 throws', () => validate('1.2.3.0/21')); testThrows('bad IP throws', () => validate('999.0.0.1/32')); testThrows('IPv6 throws', () => validate('::1')); // overlaps test('overlap subnet in block', overlaps('10.0.1.0/24', '10.0.0.0/8'), true); test('overlap exact match', overlaps('192.168.0.0/24', '192.168.0.0/24'), true); test('no overlap public', overlaps('8.8.8.0/24', '9.9.9.0/24'), false); // ──────────────────────────────────────────────────────────────── // 4. src/auth.js — session middleware (async, без JWT, без БД) // ──────────────────────────────────────────────────────────────── process.env.DEV_MODE = 'true'; process.env.ADMIN_CLIENT_ID = 'WZ01112'; const { initAuth, requireAdmin } = require('../src/auth'); const { safeReturn } = require('../src/routes/auth'); const csrfMiddleware = require('../src/middleware/csrf'); (async () => { section('auth.js (session middleware)'); try { const auth = await initAuth(); test('middleware is function', typeof auth.middleware, 'function'); test('jwksHandler present (mock mode)', typeof auth.jwksHandler, 'function'); test('devLoginEnabled when DEV_MODE', auth.devLoginEnabled, true); test('isOidc false (no KC creds)', auth.isOidc, false); // ── Пользователь из session ── const mockUser = { clientId: 'WZ01325', companyId: 'test-uuid', companyName: 'Test', email: 'test@test.local', isAdmin: false }; const reqSess = { headers: {}, cookies: {}, session: { user: mockUser } }; let nextCalled = false; auth.middleware(reqSess, {}, () => { nextCalled = true; }); test('session user: next() called', nextCalled, true); test('session user: req.user set', !!reqSess.user, true); test('session user: clientId match', reqSess.user.clientId, 'WZ01325'); // ── Admin user из session ── const adminUser = { clientId: 'WZ01112', isAdmin: true }; const reqAdmin = { headers: {}, cookies: {}, session: { user: adminUser } }; let adminNext = false; auth.middleware(reqAdmin, {}, () => { adminNext = true; }); test('admin session: next() called', adminNext, true); test('admin session: isAdmin=true', reqAdmin.user.isAdmin, true); // ── Нет аутентификации → редирект ── let redirectUrl = null; const reqNone = { headers: {}, cookies: {}, session: {}, method: 'GET', originalUrl: '/some-page' }; auth.middleware(reqNone, { redirect: (url) => { redirectUrl = url; } }, () => {}); test('no auth: redirected to /login', redirectUrl !== null && redirectUrl.startsWith('/login'), true); // ── requireAdmin: admin проходит ── let adminPassed = false; requireAdmin({ user: { isAdmin: true } }, {}, () => { adminPassed = true; }); test('requireAdmin: admin passes', adminPassed, true); // ── requireAdmin: не-admin блокируется ── let blockedStatus = null; requireAdmin({ user: { isAdmin: false } }, { status: (s) => { blockedStatus = s; return { send: () => {} }; }, }, () => {}); test('requireAdmin: non-admin blocked (403)', blockedStatus, 403); } catch (e) { console.error('auth.js FATAL:', e.message); fail++; } // ──────────────────────────────────────────────────────────────── // 5. safeReturn — защита от open redirect (A01) // ──────────────────────────────────────────────────────────────── section('safeReturn (open redirect protection)'); test('normal path allowed', safeReturn('/dashboard'), '/dashboard'); test('root / allowed', safeReturn('/'), '/'); test('path with query allowed', safeReturn('/edit?id=5'), '/edit?id=5'); test('//evil.com blocked', safeReturn('//evil.com'), '/'); test('https://evil.com blocked', safeReturn('https://evil.com'), '/'); test('http://evil.com blocked', safeReturn('http://evil.com'), '/'); test('undefined → /', safeReturn(undefined), '/'); test('null → /', safeReturn(null), '/'); test('empty string → /', safeReturn(''), '/'); test('//ok.com/path blocked', safeReturn('//ok.com/path'), '/'); test('nested path allowed', safeReturn('/admin/users'), '/admin/users'); // ──────────────────────────────────────────────────────────────── // 6. CSRF middleware — getSessionIdentifier использует req.sessionID // ──────────────────────────────────────────────────────────────── section('CSRF getSessionIdentifier'); // Проверяем что csrf.js экспортирует объект с initCsrf test('csrf module exports initCsrf', typeof csrfMiddleware.initCsrf, 'function'); // Проверяем поведение getSessionIdentifier через тестовые значения // Функция в модуле читает req.sessionID (не req.cookies.jwt) const { doubleCsrfProtection: dp, generateCsrfToken: gct } = csrfMiddleware.initCsrf(); test('initCsrf returns doubleCsrfProtection fn', typeof dp, 'function'); test('initCsrf returns generateCsrfToken fn', typeof gct, 'function'); // ──────────────────────────────────────────────────────────────── // 7. Export auth isolation — req.user влияет на фильтр компании // ──────────────────────────────────────────────────────────────── section('Export auth isolation'); // Тестируем логику без реального HTTP — проверяем что createRouter не бросает при require const exportModule = require('../src/routes/export'); test('export module exports createRouter', typeof exportModule.createRouter, 'function'); // Логика изоляции: обычный пользователь получает companyId из q.getOrCreateCompany, // admin без ?company → companyId = null (все компании). // Проверяем через минимальный stub — без реальной БД. let exportCompanyIdCaptured = null; const stubQ = { getOrCreateCompany: async (clientId) => ({ id: 42, clientId }), getExportCIDRs: async (cid) => { exportCompanyIdCaptured = cid; return []; }, }; const exportRouter = exportModule.createRouter({ q: stubQ, exportLimiter: (req, res, next) => next(), aggregateCIDRs: (x) => x, }); // Симулируем GET /export для обычного пользователя const mockReqUser = { user: { isAdmin: false, clientId: 'WZ01325', companyName: 'TestCo' }, query: {}, method: 'GET', path: '/export', }; const mockResUser = { setHeader: () => {}, send: () => {}, }; // Найдём обработчик /export в router.stack const exportLayer = exportRouter.stack.find(l => l.route && l.route.path === '/export'); test('export route registered', !!exportLayer, true); if (exportLayer) { // Запускаем handler напрямую const handlers = exportLayer.route.stack.map(l => l.handle); // exportLimiter (stub пропускает) + async handler exportCompanyIdCaptured = 'NOT_CALLED'; await handlers[handlers.length - 1](mockReqUser, mockResUser, () => {}); test('user export: companyId filtered (42)', exportCompanyIdCaptured, 42); // Симулируем GET /export для admin без ?company exportCompanyIdCaptured = 'NOT_CALLED'; const mockReqAdmin = { user: { isAdmin: true, clientId: 'WZ01112', companyName: 'Admin' }, query: {}, method: 'GET', path: '/export', }; await handlers[handlers.length - 1](mockReqAdmin, mockResUser, () => {}); test('admin export: no filter (null)', exportCompanyIdCaptured, null); } // ──────────────────────────────────────────────────────────────── // Итог // ──────────────────────────────────────────────────────────────── console.log('\n══════════════════════════════'); console.log(pass + ' PASS, ' + fail + ' FAIL'); console.log('══════════════════════════════'); process.exit(fail > 0 ? 1 : 0); })().catch(e => { console.error('\nTest runner fatal error:', e.message); process.exit(1); });