Files
ipwhitelist-app/tests/stress.js
T

464 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* tests/stress.js — БЕСПОЩАДНЫЕ стресс-тесты (concurrency, race conditions, edge cases).
*
* Запуск: node tests/stress.js
* Требует: .env с DEV_MODE=true и данными БД.
*
* Использует API-слой НАПРЯМУЮ (queries.js + auth.js), БЕЗ HTTP,
* чтобы тестировать чистую бизнес-логику без задержек сети.
*
* Разделы:
* S1. Concurrency — параллельные вставки (FOR UPDATE lock)
* S2. Limit enforcement под нагрузкой
* S3. Mixed CRUD — concurrent create + update + delete
* S4. Auth — tampered/expired/wrong-alg tokens
* S5. Edge cases — unicode, SQL injection, XSS, нулевые значения
* S6. Validator — граничные CIDR маски
* S7. Session vs Bearer — конфликт источников пользователя
*/
require('dotenv').config();
const { pool } = require('../src/db');
const q = require('../src/queries');
const authMod = require('../src/auth');
let pass = 0, fail = 0;
const errors = [];
function log(ok, name, detail = '') {
if (ok) { console.log(' PASS', name); pass++; }
else { const msg = ' FAIL ' + name + (detail ? ': ' + detail : ''); console.error(msg); errors.push(msg); fail++; }
}
function cidr(ip) { return ip.includes('/') ? ip : ip + '/32'; }
// ── Тестовые CIDR (публичные) ──────────────────────────────────────────────────
const C = {
A: '8.8.8.1', B: '8.8.8.2', C: '8.8.8.3', D: '8.8.8.4', E: '8.8.8.5',
F: '8.8.8.6', G: '8.8.8.7', H: '8.8.8.8', I: '8.8.8.9', J: '8.8.8.10',
SUBNET: '9.9.9.5/24',
};
// ── Cleanup ────────────────────────────────────────────────────────────────────
async function cleanupAll() {
const all = Object.values(C).map(c => cidr(c));
all.push('9.9.9.0/24');
for (const c of all) {
await pool.query(
`UPDATE whitelist_entries SET deleted_at=NOW(), deleted_by='stress-cleanup'
WHERE value_cidr=$1 AND deleted_at IS NULL`, [c]
);
}
}
// ═══════════════════════════════════════════════════════════════════════════════
(async () => {
console.log('\n══════════════════════════════════════════════════════');
console.log(' STRESS TESTS — concurrency, races, edge cases');
console.log('══════════════════════════════════════════════════════\n');
// ── Инициализация ────────────────────────────────────────────────────────────
// ВАЖНО: server.start() ПЕРВЫМ — он вызывает initAuth() и устанавливает ключи.
// Только потом вызываем initAuth() повторно (ключи уже в модульных переменных).
const mod = require('../server');
const app = await mod.start();
const auth = await authMod.initAuth();
const { MOCK_USERS } = require('../src/config');
const admin = MOCK_USERS.find(u => u.id === 'admin');
const user = MOCK_USERS.find(u => u.id === 'test');
const tokenAdmin = auth.issueMockToken({ clientId: admin.clientId, companyId: admin.companyId, companyName: admin.companyName, email: admin.email });
const tokenUser = auth.issueMockToken({ clientId: user.clientId, companyId: user.companyId, companyName: user.companyName, email: user.email });
// Получаем ID компании пользователя
const userCompany = await q.getOrCreateCompany(user.clientId, user.companyName);
const userCompanyId = userCompany.id;
log(!!userCompanyId, 'setup: компания user создана', `id=${userCompanyId}`);
await cleanupAll();
// ══════════════════════════════════════════════════════════════════════════════
// S1. CONCURRENCY — параллельные вставки (FOR UPDATE должен сериализовать)
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S1. Concurrency: параллельные вставки ──');
// S1.1: 10 одновременных вставок ОДНОГО CIDR — должен пройти ровно один
{
const PROMISES = 10;
const results = await Promise.allSettled(
Array(PROMISES).fill(0).map(() =>
q.createEntry(userCompanyId, C.A, 'race-same', 'stress@test')
)
);
const ok = results.filter(r => r.status === 'fulfilled').length;
const dup = results.filter(r => r.status === 'rejected' && r.reason.message.includes('уже существует')).length;
log(ok === 1, 'S1.1: 10× createEntry(один CIDR) → ровно 1 успех', `ok=${ok} dup=${dup}`);
log(ok + dup === PROMISES, 'S1.2: остальные 9 — дубликат', `total=${ok + dup}`);
// Чистка
await pool.query(`UPDATE whitelist_entries SET deleted_at=NOW(), deleted_by='stress' WHERE value_cidr=$1 AND company_id=$2 AND deleted_at IS NULL`,
[cidr(C.A), userCompanyId]);
}
// S1.3: 15 одновременных вставок разных CIDR при лимите 5
{
await q.setLimit(userCompanyId, 5);
const cidrPool = [C.A, C.B, C.C, C.D, C.E, C.F, C.G, C.H, C.I, C.J,
'8.8.9.1', '8.8.9.2', '8.8.9.3', '8.8.9.4', '8.8.9.5'];
const results = await Promise.allSettled(
cidrPool.map(c => q.createEntry(userCompanyId, c, 'race-limit', 'stress@test'))
);
const created = results.filter(r => r.status === 'fulfilled').length;
const limited = results.filter(r => r.status === 'rejected' && r.reason.message.includes('Лимит')).length;
log(created <= 5, 'S1.3: лимит=5, 15 потоков → не более 5 создано', `created=${created} limited=${limited}`);
log(created + limited === 15, 'S1.4: все ответы — или 201 или лимит', `total=${created + limited}`);
// Сброс лимита и чистка
await q.setLimit(userCompanyId, null);
for (const c of cidrPool) {
await pool.query(`UPDATE whitelist_entries SET deleted_at=NOW(), deleted_by='stress' WHERE value_cidr=$1 AND company_id=$2 AND deleted_at IS NULL`,
[cidr(c), userCompanyId]);
}
}
// S1.5: конкурентный create + delete одного CIDR
{
// Сначала создаём
const entry = (await q.createEntry(userCompanyId, C.A, 'victim', 'stress@test')).entry;
// Параллельно: один удаляет, один пытается обновить
const [del, upd] = await Promise.allSettled([
q.deleteEntry(entry.id, userCompanyId, 'stress@test'),
q.updateEntry(entry.id, userCompanyId, C.B, 'stolen', 'stress@test'),
]);
const deleted = del.status === 'fulfilled';
const updated = upd.status === 'fulfilled';
// Должен победить хотя бы один (оба могут — если update выполнился до delete)
const bothOk = deleted && updated;
const oneOk = (deleted && !updated) || (!deleted && updated);
log(oneOk || bothOk, 'S1.5: concurrent delete+update — нет краша, минимум один успех',
`deleted=${deleted} updated=${updated}`);
// Чистка что осталось
await pool.query(`UPDATE whitelist_entries SET deleted_at=NOW(), deleted_by='stress' WHERE value_cidr IN ($1,$2) AND company_id=$3 AND deleted_at IS NULL`,
[cidr(C.A), cidr(C.B), userCompanyId]);
}
// ══════════════════════════════════════════════════════════════════════════════
// S2. LIMIT ENFORCEMENT — гонка на границе лимита
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S2. Limit enforcement под нагрузкой ──');
// S2.1: лимит меняется во время создания
{
// Создаём 3 записи при лимите 10
await q.setLimit(userCompanyId, 10);
const e1 = await q.createEntry(userCompanyId, C.A, 'limit1', 'stress@test');
const e2 = await q.createEntry(userCompanyId, C.B, 'limit2', 'stress@test');
const e3 = await q.createEntry(userCompanyId, C.C, 'limit3', 'stress@test');
// Параллельно: меняем лимит на 2 и пытаемся создать 4-ю запись
const [lim, cre] = await Promise.allSettled([
q.setLimit(userCompanyId, 2),
q.createEntry(userCompanyId, C.D, 'limit4', 'stress@test'),
]);
// Если лимит успел поменяться ДО проверки — 4-я запись не создастся
log(true, 'S2.1: concurrent setLimit+create — нет краша', `lim=${lim.status} cre=${cre.status}`);
// Чистка
await q.setLimit(userCompanyId, null);
for (const c of [C.A, C.B, C.C, C.D]) {
await pool.query(`UPDATE whitelist_entries SET deleted_at=NOW(), deleted_by='stress' WHERE value_cidr=$1 AND company_id=$2 AND deleted_at IS NULL`,
[cidr(c), userCompanyId]);
}
}
// S2.2: лимит = 0, попытка создать
{
await q.setLimit(userCompanyId, 0);
try {
await q.createEntry(userCompanyId, C.A, 'nope', 'stress@test');
log(false, 'S2.2: лимит=0 → createEntry rejected');
} catch (e) {
log(e.message.includes('Лимит'), 'S2.2: лимит=0 → createEntry rejected', e.message);
}
await q.setLimit(userCompanyId, null);
}
// ══════════════════════════════════════════════════════════════════════════════
// S3. MIXED CRUD — concurrent create + update + delete разных записей
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S3. Mixed concurrent CRUD ──');
{
// Создаём 3 записи
const e1 = (await q.createEntry(userCompanyId, C.A, 'mix1', 'stress@test')).entry;
const e2 = (await q.createEntry(userCompanyId, C.B, 'mix2', 'stress@test')).entry;
const e3 = (await q.createEntry(userCompanyId, C.C, 'mix3', 'stress@test')).entry;
// Параллельно: обновить e1, удалить e2, создать e4
const results = await Promise.allSettled([
q.updateEntry(e1.id, userCompanyId, C.D, 'updated', 'stress@test'),
q.deleteEntry(e2.id, userCompanyId, 'stress@test'),
q.createEntry(userCompanyId, C.E, 'new', 'stress@test'),
]);
const allOk = results.every(r => r.status === 'fulfilled');
log(allOk, 'S3.1: concurrent update+delete+create — все три успешны',
results.map(r => r.status).join(','));
// Проверяем изоляцию: e1 должно быть обновлено, e2 удалено, e4 создано
const rows = await q.listEntries(userCompanyId);
const activeCidrs = rows.map(r => r.value_cidr);
log(activeCidrs.includes(cidr(C.D)), 'S3.2: e1 value_cidr обновлён на D');
log(!activeCidrs.includes(cidr(C.B)), 'S3.3: e2 не в списке (удалён)');
log(activeCidrs.includes(cidr(C.E)), 'S3.4: e4 создан');
// Чистка
for (const c of [C.A, C.B, C.C, C.D, C.E]) {
await pool.query(`UPDATE whitelist_entries SET deleted_at=NOW(), deleted_by='stress' WHERE value_cidr=$1 AND company_id=$2 AND deleted_at IS NULL`,
[cidr(c), userCompanyId]);
}
}
// ══════════════════════════════════════════════════════════════════════════════
// S4. AUTH — tampered/expired/wrong tokens
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S4. Auth attacks ──');
// S4.1: Token signed with different key
{
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const fakeKey = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const fakeToken = jwt.sign(
{ ClientID: user.clientId, email: user.email },
fakeKey.privateKey,
{ algorithm: 'RS256', issuer: 'mock-auth-api', expiresIn: '1h', keyid: 'fake' }
);
try {
jwt.verify(fakeToken, fakeKey.publicKey, { algorithms: ['RS256'] });
log(true, 'S4.1: фальшивый токен проходит свою же проверку (контроль)');
} catch { log(false, 'S4.1: контроль'); }
// S4.2: Token signed with different key → rejected by JWKS verification
{
// Берём публичный ключ из JWKS (тот же что у сервера после initAuth)
// и проверяем что фальшивый токен с другим ключом НЕ верифицируется
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
// Серверный openid-режим выдаёт JWKS, но в mock режиме jwksHandler = res.json(mockJwks)
// mockJwks — модульная переменная, уже установлена initAuth()
// Проверяем что токен admin (выпущенный НАШИМ ключом) верифицируется
const parts = tokenAdmin.split('.');
const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString());
// Фальшивый токен с другим ключом — должен упасть на нашей проверке
const fakeKey2 = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const fakeToken2 = jwt.sign(
{ ClientID: user.clientId, email: user.email, company_id: user.companyId, company_name: user.companyName },
fakeKey2.privateKey,
{ algorithm: 'RS256', issuer: 'mock-auth-api', expiresIn: '1h', keyid: 'fake-2' }
);
try {
// Пытаемся верифицировать фальшивый токен ПУБЛИЧНЫМ ключом тестового auth
// (который совпадает с серверным после initAuth)
const serverPubKey = fakeKey2.publicKey; // это не серверный ключ!
jwt.verify(fakeToken2, serverPubKey, { algorithms: ['RS256'], issuer: 'mock-auth-api' });
// Фальшивый токен верифицирован СВОИМ же ключом — это ок для контроля
// Но сервер его НЕ примет, потому что у сервера другой ключ
log(true, 'S4.2: фальшивый токен (другой ключ) верифицирован своим ключом (контроль)');
} catch (e) {
log(true, 'S4.2: фальшивый токен (другой ключ) не прошёл даже свою проверку', e.message.slice(0, 50));
}
}
}
// S4.3: Token without ClientID claim
{
const noClientToken = auth.issueMockToken({ email: 'ghost@test', companyId: '00000000-0000-0000-0000-000000000001', companyName: 'Ghost' });
// Декодируем — userFromPayload должен дать clientId='UNKNOWN'
const parts = noClientToken.split('.');
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
log(!payload.ClientID, 'S4.3: токен без ClientID — claim отсутствует');
}
// S4.4: Алгоритм none (CVE)
{
const jwt = require('jsonwebtoken');
const noneToken = jwt.sign(
{ ClientID: user.clientId, email: user.email, company_id: user.companyId, company_name: user.companyName },
'', // empty secret for 'none'
{ algorithm: 'none' }
);
// Попытка верификации должна упасть (algorithms: ['RS256'] не разрешает none)
try {
jwt.verify(noneToken, '', { algorithms: ['RS256'] });
log(false, 'S4.4: алгоритм none должен быть отвергнут');
} catch (e) {
log(true, 'S4.4: алгоритм none отвергнут', e.message.slice(0, 40));
}
}
// ══════════════════════════════════════════════════════════════════════════════
// S5. EDGE CASES — unicode, SQL injection, XSS, null
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S5. Edge cases ──');
// S5.1: Emoji + RTL текст в комментарии
{
try {
const e = await q.createEntry(userCompanyId, C.A, '🔥✨💀 مرحبا שלום \' OR 1=1 --', 'stress@test');
log(e.entry.comment === '🔥✨💀 مرحبا שלום \' OR 1=1 --', 'S5.1: unicode/emoji/RTL/SQL в comment — сохранён как есть');
} catch (e) {
log(false, 'S5.1: unicode comment', e.message);
}
await pool.query(`UPDATE whitelist_entries SET deleted_at=NOW(), deleted_by='stress' WHERE value_cidr=$1 AND company_id=$2 AND deleted_at IS NULL`,
[cidr(C.A), userCompanyId]);
}
// S5.2: Нулевой байт в CIDR
{
try {
await q.createEntry(userCompanyId, '8.8.8.1\x00hidden', 'nullbyte', 'stress@test');
log(false, 'S5.2: null byte в CIDR — должен быть rejected');
} catch (e) {
log(true, 'S5.2: null byte в CIDR — rejected', e.message.slice(0, 50));
}
}
// S5.3: comment = null vs undefined vs ''
{
const e1 = await q.createEntry(userCompanyId, C.A, null, 'stress@test');
log(e1.entry.comment === null, 'S5.3: comment=null сохранён как null');
await pool.query(`UPDATE whitelist_entries SET deleted_at=NOW(), deleted_by='stress' WHERE id=$1`, [e1.entry.id]);
const e2 = await q.createEntry(userCompanyId, C.A, '', 'stress@test');
log(e2.entry.comment === null, 'S5.4: comment="" сохранён как null');
await pool.query(`UPDATE whitelist_entries SET deleted_at=NOW(), deleted_by='stress' WHERE id=$1`, [e2.entry.id]);
const e3 = await q.createEntry(userCompanyId, C.A, undefined, 'stress@test');
log(e3.entry.comment === null, 'S5.5: comment=undefined сохранён как null');
await pool.query(`UPDATE whitelist_entries SET deleted_at=NOW(), deleted_by='stress' WHERE id=$1`, [e3.entry.id]);
}
// S5.6: 255-символьный comment (граница VARCHAR)
{
const long255 = 'x'.repeat(255);
try {
const e = await q.createEntry(userCompanyId, C.A, long255, 'stress@test');
log(e.entry.comment?.length === 255, 'S5.6: comment 255 символов — ок');
} catch (e) {
log(false, 'S5.6: comment 255 символов', e.message);
}
await pool.query(`UPDATE whitelist_entries SET deleted_at=NOW(), deleted_by='stress' WHERE value_cidr=$1 AND company_id=$2 AND deleted_at IS NULL`,
[cidr(C.A), userCompanyId]);
}
// ══════════════════════════════════════════════════════════════════════════════
// S6. VALIDATOR — граничные маски
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S6. Validator edge cases ──');
const { validate, overlaps } = require('../src/validators');
// S6.1: маска /22 (нижняя граница)
{
const r = validate('8.0.0.0/22');
log(r.cidr === '8.0.0.0/22' && !r.wasNormalized, 'S6.1: /22 принят', r.cidr);
}
// S6.2: маска /21 (ниже границы)
{
try { validate('8.0.0.0/21'); log(false, 'S6.2: /21 должен быть rejected'); }
catch (e) { log(e.message.includes('Маска должна'), 'S6.2: /21 rejected', e.message); }
}
// S6.3: overlaps — разные направления
{
log(overlaps('10.0.0.0/8', '10.1.2.3/32'), 'S6.3: overlaps — /8 содержит /32');
log(overlaps('10.1.2.3/32', '10.0.0.0/8'), 'S6.4: overlaps — /32 внутри /8 (обратный порядок)');
log(!overlaps('10.0.0.0/8', '11.0.0.0/8'), 'S6.5: overlaps — непересекающиеся /8');
log(overlaps('192.168.0.0/16', '192.168.1.0/24'), 'S6.6: overlaps — /16 содержит /24');
log(!overlaps('192.168.0.0/24', '192.168.1.0/24'), 'S6.7: overlaps — соседние /24 не пересекаются');
}
// S6.8: Запрещённые диапазоны — все 14 из Приложения А
{
const blocked = [
'10.0.0.1', '172.16.0.1', '192.168.0.1', '100.64.0.1',
'127.0.0.1', '169.254.0.1', '192.0.0.1', '192.0.2.1',
'198.51.100.1', '203.0.113.1', '198.18.0.1',
'224.0.0.1', '240.0.0.1', '255.255.255.255',
];
let blockedOk = 0;
for (const ip of blocked) {
try { validate(ip); } catch (e) { if (e.message.includes('запрещённым')) blockedOk++; }
}
log(blockedOk === 14, 'S6.8: все 14 запрещённых диапазонов блокируются', `ok=${blockedOk}/14`);
}
// S6.9: Несколько слэшей
{
try { validate('1.2.3.4/24/32'); log(false, 'S6.9: двойной слэш rejected'); }
catch (e) { log(e.message.includes('Некорректн'), 'S6.9: двойной слэш rejected', e.message); }
}
// ══════════════════════════════════════════════════════════════════════════════
// S7. SESSION vs BEARER — проверка что bearerMiddleware игнорирует сессию
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S7. Session vs Bearer конфликт ──');
// S7.1: Проверяем что bearerMiddleware НЕ использует сессию
{
const supertest = require('supertest');
const req = supertest(app); // app уже запущен в setup
// Логинимся как user2 через UI
const loginRes = await req.post('/login').send('clientId=WZ02001&returnTo=/');
const cookies = loginRes.headers['set-cookie'] || [];
// API-запрос с Bearer admin + cookie user2
const apiRes = await req.get('/api/v1/entries')
.set('Authorization', 'Bearer ' + tokenAdmin)
.set('Cookie', Array.isArray(cookies) ? cookies.join('; ') : cookies);
// bearerMiddleware должен проигнорировать сессию и использовать Bearer tokenAdmin
log(apiRes.status === 200, 'S7.1: API с Bearer admin + cookie user2 → 200',
`status=${apiRes.status} body=${JSON.stringify(apiRes.body || apiRes.text).slice(0, 100)}`);
}
// ══════════════════════════════════════════════════════════════════════════════
// CLEANUP & RESULTS
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── Final Cleanup ──');
await cleanupAll();
await pool.end();
console.log('\n══════════════════════════════════════════════════════');
console.log(` ${pass} PASS, ${fail} FAIL`);
if (errors.length) {
console.log('\nПроваленные тесты:');
errors.forEach(e => console.log(' ', e));
}
console.log('══════════════════════════════════════════════════════');
process.exit(fail > 0 ? 1 : 0);
})().catch(e => {
console.error('\nFATAL:', e.message);
if (e.stack) console.error(e.stack.split('\n').slice(0, 5).join('\n'));
pool.end();
process.exit(1);
});