diff --git a/tests/run-tests.js b/tests/run-tests.js new file mode 100644 index 0000000..90c2611 --- /dev/null +++ b/tests/run-tests.js @@ -0,0 +1,179 @@ +'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 — DEV_MODE (без JWT, без БД) +// ──────────────────────────────────────────────────────────────── +section('auth.js (DEV_MODE)'); + +process.env.DEV_MODE = 'true'; +process.env.DEV_ADMIN = 'true'; +process.env.ADMIN_CLIENT_ID = 'WZ01112'; + +const { initAuth, requireAdmin } = require('../src/auth'); + +try { + const auth = initAuth(); + test('middleware is function', typeof auth.middleware, 'function'); + test('jwksHandler present in DEV_MODE', typeof auth.jwksHandler, 'function'); + + // DEV_MODE: middleware выставляет req.user без JWT + const req = { headers: {}, cookies: {} }; + const res = { redirect: () => {} }; + let nextCalled = false; + auth.middleware(req, res, () => { nextCalled = true; }); + test('DEV_MODE: next() called', nextCalled, true); + test('DEV_MODE: req.user set', !!req.user, true); + test('DEV_MODE: req.user.isAdmin = true (DEV_ADMIN=true)', req.user.isAdmin, true); + + // requireAdmin: пропускает admin + const reqAdmin = { user: { isAdmin: true } }; + let adminPassed = false; + requireAdmin(reqAdmin, {}, () => { adminPassed = true; }); + test('requireAdmin: admin passes', adminPassed, true); + + // requireAdmin: блокирует не-admin + const reqUser = { user: { isAdmin: false } }; + let blockedStatus = null; + requireAdmin(reqUser, { + status: (s) => { blockedStatus = s; return { send: () => {} }; }, + }, () => {}); + test('requireAdmin: non-admin blocked (403)', blockedStatus, 403); + +} catch (e) { + console.error('auth.js FATAL:', e.message); + fail++; +} + +// ──────────────────────────────────────────────────────────────── +// Итог +// ──────────────────────────────────────────────────────────────── +console.log('\n══════════════════════════════'); +console.log(pass + ' PASS, ' + fail + ' FAIL'); +console.log('══════════════════════════════'); +process.exit(fail > 0 ? 1 : 0);