Files
ipwhitelist-app/tests/stress.js

1041 lines
59 KiB
JavaScript
Raw Permalink 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 === 'alfa');
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)}`);
}
// ══════════════════════════════════════════════════════════════════════════════
// S8. ALL MASKS /0 through /33 — каждая маска отдельно
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S8. All CIDR masks /0 … /33 ──');
for (let m = 0; m <= 33; m++) {
const cidr = `85.0.0.0/${m}`;
try {
const r = validate(cidr);
const ok = m >= 22 && m <= 32;
log(ok, `S8.m${m}: /${m}${ok ? 'accepted' : 'should reject'}`, ok ? r.cidr : '');
} catch (e) {
const ok = m < 22 || m > 32;
log(ok, `S8.m${m}: /${m} → rejected`, e.message.slice(0, 40));
}
}
// ══════════════════════════════════════════════════════════════════════════════
// S9. IP BOUNDARY VALUES — экстремальные IP-адреса
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S9. IP boundary values ──');
const boundaryIPs = [
['0.0.0.0', true, '/32 → 0.0.0.0/32 блокируется (блок 0/8) или ок'],
['0.0.0.1', true, '0.0.0.1 — 0/8 не в BLOCKED_RANGES, публичный'],
['1.0.0.0', true, '1.0.0.0 — публичный'],
['1.1.1.1', true, 'Cloudflare DNS'],
['8.8.8.8', true, 'Google DNS'],
['9.9.9.9', true, 'Quad9'],
['126.255.255.255', true, 'край /8 без 127'],
['127.0.0.1', false, 'loopback блокируется'],
['127.255.255.254', false, 'loopback блокируется'],
['128.0.0.0', true, 'первый class B'],
['169.254.0.1', false, 'link-local блокируется'],
['169.254.255.254', false, 'link-local блокируется'],
['172.15.255.255', true, 'перед 172.16/12 — публичный'],
['172.16.0.1', false, '172.16/12 приватный'],
['172.31.255.254', false, '172.16/12 приватный'],
['172.32.0.1', true, 'после 172.16/12 — публичный'],
['192.0.0.1', false, '192.0.0/24 IANA special'],
['192.0.1.1', true, 'после 192.0.0/24'],
['192.0.2.1', false, 'TEST-NET-1'],
['192.168.0.1', false, '192.168/16 приватный'],
['192.168.255.254', false, '192.168/16 приватный'],
['192.169.0.1', true, 'после 192.168/16'],
['198.18.0.1', false, '198.18/15 benchmark'],
['198.19.255.254', false, '198.18/15 benchmark'],
['198.51.100.1', false, 'TEST-NET-2'],
['203.0.113.1', false, 'TEST-NET-3'],
['223.255.255.255', true, 'последний перед multicast'],
['224.0.0.1', false, 'multicast'],
['239.255.255.255', false, 'multicast'],
['240.0.0.1', false, 'зарезервировано'],
['255.255.255.254', false, 'broadcast range'],
['255.255.255.255', false, 'limited broadcast'],
];
let b9pass = 0;
for (const [ip, shouldPass, desc] of boundaryIPs) {
try {
validate(ip);
if (shouldPass) b9pass++;
log(shouldPass, `S9: ${ip} (${desc}) — ${shouldPass ? 'accepted' : 'unexpected pass'}`);
} catch (e) {
if (!shouldPass) b9pass++;
log(!shouldPass, `S9: ${ip} (${desc}) — rejected`, e.message.slice(0, 40));
}
}
// ══════════════════════════════════════════════════════════════════════════════
// S10. AUTH — token manipulation (expired, future, claims)
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S10. Auth: token manipulation ──');
{
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
// S10.1: Expired token
try {
const expToken = jwt.sign(
{ ClientID: user.clientId, email: user.email, company_id: user.companyId, company_name: user.companyName },
crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey,
{ algorithm: 'RS256', issuer: 'mock-auth-api', expiresIn: '0s' }
);
log(true, 'S10.1: expired token created (будет rejected при verify)');
} catch (e) { log(false, 'S10.1: expired token', e.message); }
// S10.2: Wrong issuer
try {
const wrongIss = jwt.sign(
{ ClientID: user.clientId, email: user.email }, crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey,
{ algorithm: 'RS256', issuer: 'evil-issuer', expiresIn: '1h', keyid: 'bad' }
);
log(true, 'S10.2: токен с wrong issuer создан');
} catch (e) { log(false, 'S10.2: wrong issuer', e.message); }
// S10.3: Future token (iat в будущем — должно приниматься, это не ошибка)
try {
const futToken = jwt.sign(
{ ClientID: user.clientId, email: user.email, company_id: user.companyId, company_name: user.companyName },
crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey,
{ algorithm: 'RS256', issuer: 'mock-auth-api', expiresIn: '100y', keyid: 'future' }
);
log(true, 'S10.3: future token (100y) created');
} catch (e) { log(false, 'S10.3: future token', e.message); }
// S10.4: Missing company_id → userFromPayload → companyId=sub
try {
const noCoToken = auth.issueMockToken({ clientId: user.clientId, email: user.email });
const parts = noCoToken.split('.');
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
log(!payload.company_id, 'S10.4: токен без company_id — claim отсутствует');
} catch (e) { log(false, 'S10.4: no company_id', e.message); }
// S10.5: Missing email → userFromPayload → email='unknown'
try {
const noEmailToken = auth.issueMockToken({ clientId: user.clientId, companyId: user.companyId, companyName: user.companyName });
const parts = noEmailToken.split('.');
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
log(!payload.email, 'S10.5: токен без email — claim отсутствует');
} catch (e) { log(false, 'S10.5: no email', e.message); }
// S10.6: Empty ClientID
try {
const emptyClientToken = auth.issueMockToken({ clientId: '', companyId: user.companyId, companyName: user.companyName, email: user.email });
log(!!emptyClientToken, 'S10.6: токен с пустым ClientID — создан (userFromPayload даст UNKNOWN)');
} catch (e) { log(false, 'S10.6: empty ClientID', e.message); }
}
// ══════════════════════════════════════════════════════════════════════════════
// S11. DB — двойные операции, несуществующие записи
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S11. DB edge cases ──');
// S11.1: Двойное удаление
{
const e = (await q.createEntry(userCompanyId, C.A, 'double-del', 'stress@test')).entry;
await q.deleteEntry(e.id, userCompanyId, 'stress@test');
try {
await q.deleteEntry(e.id, userCompanyId, 'stress@test');
log(false, 'S11.1: double delete — должен быть rejected');
} catch (err) {
log(err.message.includes('Запись не найдена') || err.message.includes('не найдена'),
'S11.1: double delete rejected', err.message.slice(0, 40));
}
}
// S11.2: Обновление удалённой записи
{
const e = (await q.createEntry(userCompanyId, C.A, 'upd-del', 'stress@test')).entry;
await q.deleteEntry(e.id, userCompanyId, 'stress@test');
try {
await q.updateEntry(e.id, userCompanyId, C.B, 'ghost', 'stress@test');
log(false, 'S11.2: update deleted — должен быть rejected');
} catch (err) {
log(err.message.includes('Запись не найдена') || err.message.includes('не найдена'),
'S11.2: update deleted rejected', err.message.slice(0, 40));
}
}
// S11.3: Несуществующий entry ID
try {
await q.updateEntry(9999999, userCompanyId, C.A, 'nope', 'stress@test');
log(false, 'S11.3: update несуществующего ID rejected');
} catch (err) {
log(true, 'S11.3: update несуществующего ID rejected', err.message.slice(0, 40));
}
// S11.4: Чужая компания (entry принадлежит userCompanyId, запрос от другой)
{
const e = (await q.createEntry(userCompanyId, C.A, 'isolation', 'stress@test')).entry;
const adminCompany = await q.getOrCreateCompany(admin.clientId, admin.companyName);
try {
await q.updateEntry(e.id, adminCompany.id, C.B, 'stolen', 'stress@test');
log(false, 'S11.4: update чужой записи — rejected');
} catch (err) {
log(true, 'S11.4: update чужой записи rejected', err.message.slice(0, 40));
}
await pool.query(`UPDATE whitelist_entries SET deleted_at=NOW(), deleted_by='stress' WHERE id=$1`, [e.id]);
}
// S11.5: getOrCreateCompany — повторный вызов с теми же параметрами (UPSERT)
{
const c1 = await q.getOrCreateCompany(user.clientId, user.companyName);
const c2 = await q.getOrCreateCompany(user.clientId, user.companyName);
log(c1.id === c2.id, 'S11.5: getOrCreateCompany идемпотентен', `id=${c1.id}`);
}
// S11.6: getOrCreateCompany — конкурентный UPSERT
{
const results = await Promise.allSettled(
Array(5).fill(0).map(() => q.getOrCreateCompany(user.clientId, user.companyName))
);
const allSame = results.every(r => r.status === 'fulfilled' && r.value.id === results[0].value?.id);
log(allSame, 'S11.6: 5× конкурентный getOrCreateCompany → все вернули один ID');
}
// ══════════════════════════════════════════════════════════════════════════════
// S12. UNICODE ATTACKS — нормализация, обход валидации
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S12. Unicode attacks ──');
// S12.1: Full-width digits (0134)
try { validate('8.8.8.'); log(false, 'S12.1: full-width digits rejected'); }
catch (e) { log(true, 'S12.1: full-width digits rejected', e.message.slice(0, 40)); }
// S12.2: Zero-width space in CIDR
try { validate('8.8.8.1\u200B'); log(false, 'S12.2: zero-width space rejected'); }
catch (e) { log(true, 'S12.2: zero-width space rejected', e.message.slice(0, 40)); }
// S12.3: RTL override in CIDR
try { validate('8.8.8.1\u202E'); log(false, 'S12.3: RTL override rejected'); }
catch (e) { log(true, 'S12.3: RTL override rejected', e.message.slice(0, 40)); }
// S12.4: Homoglyph attack (cyrillic 'а' instead of latin 'a')
try { validate('8.8.8.1'); log(true, 'S12.4: normal CIDR accepted (контроль)'); }
catch { log(false, 'S12.4: контроль'); }
// S12.5: Unicode normalization in comment — NFC vs NFD
{
const nfc = 'café'; // NFC (normal)
const nfd = 'cafe\u0301'; // NFD (decomposed)
const e1 = await q.createEntry(userCompanyId, C.A, nfc, 'stress@test');
const e2 = await q.createEntry(userCompanyId, C.B, nfd, 'stress@test');
log(e1.entry.comment === nfc, 'S12.5a: NFC comment сохранён');
log(e2.entry.comment === nfd, 'S12.5b: NFD comment сохранён');
await pool.query(`UPDATE whitelist_entries SET deleted_at=NOW() WHERE id IN ($1,$2)`, [e1.entry.id, e2.entry.id]);
}
// ══════════════════════════════════════════════════════════════════════════════
// S13. ADMIN PRIVILEGE ESCALATION — попытки обойти ACL
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S13. Admin privilege escalation ──');
// S13.1: Обычный юзер пытается запросить /companies
{
const supertest = require('supertest');
const req = supertest(app);
const r = await req.get('/api/v1/companies').set('Authorization', 'Bearer ' + tokenUser);
log(r.status === 403, 'S13.1: user GET /companies → 403', `status=${r.status}`);
}
// S13.2: Обычный юзер пытается менять лимит
{
const supertest = require('supertest');
const req = supertest(app);
const r = await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
.set('Authorization', 'Bearer ' + tokenUser).send({ limit: 999 });
log(r.status === 403, 'S13.2: user PATCH /limit → 403', `status=${r.status}`);
}
// S13.3: Обычный юзер пытается запросить /audit
{
const supertest = require('supertest');
const req = supertest(app);
const r = await req.get('/api/v1/audit').set('Authorization', 'Bearer ' + tokenUser);
log(r.status === 403, 'S13.3: user GET /audit → 403', `status=${r.status}`);
}
// S13.4: Обычный юзер пытается смотреть чужие записи через ?company=
{
const adminCompany = await q.getOrCreateCompany(admin.clientId, admin.companyName);
const supertest = require('supertest');
const req = supertest(app);
const r = await req.get('/api/v1/entries?company=' + adminCompany.id)
.set('Authorization', 'Bearer ' + tokenUser);
// user не admin → ?company должен игнорироваться, записи только свои
log(r.status === 200, 'S13.4: user GET /entries?company=admin_id → 200 (игнорирует ?company)', `status=${r.status}`);
}
// S13.5: Admin пытается использовать несуществующий ?company=
{
const supertest = require('supertest');
const req = supertest(app);
const r = await req.get('/api/v1/entries?company=99999999')
.set('Authorization', 'Bearer ' + tokenAdmin);
log(r.status === 404 || r.status === 500, 'S13.5: admin GET /entries?company=несуществующий → 404/500', `status=${r.status}`);
}
// ══════════════════════════════════════════════════════════════════════════════
// S14. RATE LIMIT BYPASS — попытки обойти лимиты
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S16. Session attacks ──');
// S16.1: Логин → логаут → сессия уничтожена → доступ запрещён
{
const supertest = require('supertest');
const req = supertest(app);
// Логин (НЕ следуем редиректу — куки на 302 ответе)
const loginRes = await req.post('/login').redirects(0).send('clientId=WZ01325&returnTo=/');
const cookies = loginRes.headers['set-cookie'] || [];
const cookieStr = Array.isArray(cookies) ? cookies.join('; ') : cookies;
log(loginRes.status === 302, 'S16.0: POST /login → 302 redirect', `status=${loginRes.status}`);
// Проверяем доступ
const r1 = await req.get('/').set('Cookie', cookieStr);
log(r1.status === 200, 'S16.1: после логина GET / → 200');
// Логаут
await req.get('/logout').set('Cookie', cookieStr);
// После логаута
const r2 = await req.get('/').set('Cookie', cookieStr);
log(r2.status === 302 && (r2.headers.location || '').includes('/login'),
'S16.2: после логаута → редирект на /login');
}
// S16.3: Повторное использование куки после логаута
{
const supertest = require('supertest');
const req = supertest(app);
const loginRes = await req.post('/login').send('clientId=WZ01325&returnTo=/');
const cookies = loginRes.headers['set-cookie'] || [];
const cookieStr = Array.isArray(cookies) ? cookies.join('; ') : cookies;
await req.get('/logout').set('Cookie', cookieStr);
const r = await req.get('/api/v1/entries').set('Cookie', cookieStr)
.set('Authorization', 'Bearer ' + tokenUser);
// После логаута сессия уничтожена, но Bearer всё ещё валиден → bearerMiddleware должен пустить
log(r.status === 200, 'S16.3: после логаута API с Bearer → 200 (bearerMiddleware)');
}
// ══════════════════════════════════════════════════════════════════════════════
// S17. MASSIVE CONCURRENCY — 50+ потоков
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S14. Rate limit bypass attempts ──');
// S16 (SESSION) moved BEFORE S14 чтобы rate limiter не мешал логину
// SESSION tests already ran above.
// ── Rate limit tests ──
// S14.1: 10 быстрых запросов к /export (публичный)
{
const supertest = require('supertest');
const req = supertest(app);
const results = await Promise.all(
Array(10).fill(0).map(() => req.get('/export'))
);
const all200 = results.every(r => r.status === 200);
log(all200, 'S14.1: 10× GET /export без авторизации → все 200', results.map(r => r.status).join(','));
}
// S14.2: 10 быстрых POST /login (ограничен authLimiter)
{
const supertest = require('supertest');
const req = supertest(app);
const results = await Promise.all(
Array(10).fill(0).map(() => req.post('/login').send('clientId=WZ01325&returnTo=/'))
);
const redirects = results.filter(r => r.status === 302).length;
log(redirects > 0, 'S14.2: 10× POST /login — большинство 302', `302=${redirects}/10`);
}
// ══════════════════════════════════════════════════════════════════════════════
// S15. EXPORT — edge cases
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S15. Export edge cases ──');
// S15.1: /export с пустой БД
{
const supertest = require('supertest');
const req = supertest(app);
const r = await req.get('/export');
log(r.status === 200, 'S15.1: /export → 200');
log(r.headers['content-type']?.includes('text/plain'), 'S15.2: /export Content-Type text/plain');
}
// S15.3: /export после добавления записи
{
await q.createEntry(userCompanyId, C.A, 'export-test', 'stress@test');
const supertest = require('supertest');
const req = supertest(app);
const r = await req.get('/export');
log(r.status === 200 && r.text.includes(cidr(C.A)), 'S15.3: /export содержит добавленный CIDR');
await pool.query(`UPDATE whitelist_entries SET deleted_at=NOW(), deleted_by='stress' WHERE value_cidr=$1 AND deleted_at IS NULL`,
[cidr(C.A)]);
}
// S15.4: /api/v1/entries/export через API (с авторизацией)
{
const supertest = require('supertest');
const req = supertest(app);
const r = await req.get('/api/v1/entries/export').set('Authorization', 'Bearer ' + tokenUser);
log(r.status === 200 || r.status === 404, 'S15.4: /api/v1/entries/export → 200 или 404', `status=${r.status}`);
}
// ══════════════════════════════════════════════════════════════════════════════
// S16. SESSION — fixation, hijack, cross-user
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S17. Massive concurrency ──');
// S17.1: 50 конкурентных createEntry разных CIDR
{
const cidrs = Array.from({length: 50}, (_, i) => `85.${Math.floor(i / 256)}.${i % 256}.1`);
const results = await Promise.allSettled(
cidrs.map(c => q.createEntry(userCompanyId, c, 'massive', 'stress@test'))
);
const ok = results.filter(r => r.status === 'fulfilled').length;
const limited = results.filter(r => r.status === 'rejected' && r.reason?.message?.includes('Лимит')).length;
log(ok <= 15, 'S17.1: 50 конкурентных createEntry → не более 15 (лимит)', `ok=${ok} limited=${limited}`);
log(ok + limited === 50, 'S17.2: все 50 получили ответ (201 или лимит)', `total=${ok + limited}`);
// Чистка
for (const c of cidrs) {
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]);
}
}
// S17.3: 30 конкурентных delete (всех записей после добавления 30)
{
// Добавляем 30 записей (лимит нужно поднять)
await q.setLimit(userCompanyId, 50);
const cidrs30 = Array.from({length: 30}, (_, i) => `86.${Math.floor(i / 256)}.${i % 256}.1`);
const entries = [];
for (const c of cidrs30) {
entries.push((await q.createEntry(userCompanyId, c, 'del-mass', 'stress@test')).entry);
}
log(entries.length === 30, 'S17.3a: 30 записей созданы');
// Конкурентно удаляем
const delResults = await Promise.allSettled(
entries.map(e => q.deleteEntry(e.id, userCompanyId, 'stress@test'))
);
const delOk = delResults.filter(r => r.status === 'fulfilled').length;
log(delOk === 30, 'S17.3: 30 конкурентных delete → все 30 успешны', `ok=${delOk}`);
await q.setLimit(userCompanyId, null);
}
// S17.4: 20 конкурентных getOrCreateCompany (разные компании)
{
const fakeClients = Array.from({length: 20}, (_, i) => `WZ9${String(i).padStart(4, '0')}`);
const results = await Promise.allSettled(
fakeClients.map(cid => q.getOrCreateCompany(cid, `Test-${cid}`))
);
const allOk = results.every(r => r.status === 'fulfilled' && r.value.id);
log(allOk, 'S17.4: 20 конкурентных getOrCreateCompany → все созданы');
// Чистка: удаляем созданные тестовые компании
for (const r of results) {
if (r.status === 'fulfilled') {
await pool.query(`DELETE FROM companies WHERE id=$1 AND client_id LIKE 'WZ9%'`, [r.value.id]);
}
}
}
// ══════════════════════════════════════════════════════════════════════════════
// S18. NORMALIZATION — проверка wasNormalized
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S18. CIDR normalization ──');
// S18.1S18.10: Host bits не обнулены — должны нормализоваться
const normCases = [
['1.1.1.1/24', '1.1.1.0/24', 'host bits /24'],
['8.8.8.8/22', '8.8.8.0/22', 'host bits /22'],
['85.0.0.5/30', '85.0.0.4/30', 'host bits /30'],
['85.0.0.3/31', '85.0.0.2/31', 'host bits /31'],
['85.0.0.1/32', '85.0.0.1/32', '/32 без изменений'],
['9.9.9.255/24', '9.9.9.0/24', 'последний хост /24'],
['1.2.3.128/25', '1.2.3.128/25', '/25 network boundary'],
['1.2.3.127/25', '1.2.3.0/25', '/25 host bits'],
['85.0.0.0/22', '85.0.0.0/22', 'уже нормализован'],
['85.255.255.255/22', '85.255.252.0/22', 'макс хост /22'],
];
for (const [input, expected, desc] of normCases) {
try {
const r = validate(input);
log(r.cidr === expected, `S18.norm: ${input}${expected}`, `${desc} wasNorm=${r.wasNormalized}`);
} catch (e) {
log(false, `S18.norm: ${input} → rejected`, e.message.slice(0, 40));
}
}
// ══════════════════════════════════════════════════════════════════════════════
// S19. EACH BLOCKED RANGE
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S19. Each blocked range ──');
const blockedList = [
['10.0.0.1', '10/8 private'], ['172.16.0.1', '172.16/12 private'],
['192.168.0.1', '192.168/16 private'], ['100.64.0.1', '100.64/10 CGNAT'],
['127.0.0.1', '127/8 loopback'], ['169.254.0.1', '169.254/16 link-local'],
['192.0.0.1', '192.0.0/24 IANA'], ['192.0.2.1', 'TEST-NET-1'],
['198.51.100.1', 'TEST-NET-2'], ['203.0.113.1', 'TEST-NET-3'],
['198.18.0.1', '198.18/15 benchmark'], ['224.0.0.1', '224/4 multicast'],
['240.0.0.1', '240/4 reserved'], ['255.255.255.255', 'broadcast'],
];
for (const [ip, desc] of blockedList) {
try { validate(ip); log(false, `S19: ${ip} (${desc}) — should block`); }
catch (e) { log(e.message.includes('запрещённым'), `S19: ${ip} (${desc}) — blocked`); }
}
// ══════════════════════════════════════════════════════════════════════════════
// S20. SEQUENTIAL CRUD
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S20. Sequential CRUD ──');
{
const e = (await q.createEntry(userCompanyId, C.A, 'seq1', 'stress@test')).entry;
log(!!e.id, 'S20.1a: create → id');
const u1 = await q.updateEntry(e.id, userCompanyId, C.B, 'seq2', 'stress@test');
log(u1.entry.value_cidr === cidr(C.B), 'S20.1b: update → B');
const u2 = await q.updateEntry(e.id, userCompanyId, C.C, 'seq3', 'stress@test');
log(u2.entry.value_cidr === cidr(C.C), 'S20.1c: update → C');
await q.deleteEntry(e.id, userCompanyId, 'stress@test');
const list = await q.listEntries(userCompanyId);
log(!list.find(r => r.id === e.id), 'S20.1d: delete → gone');
}
{
const e1 = (await q.createEntry(userCompanyId, C.A, 'recreate', 'stress@test')).entry;
await q.deleteEntry(e1.id, userCompanyId, 'stress@test');
const e2 = await q.createEntry(userCompanyId, C.A, 'recreated', 'stress@test');
log(e2.entry.id !== e1.id, 'S20.2: create после delete того же CIDR → new id');
await pool.query(`UPDATE whitelist_entries SET deleted_at=NOW() WHERE id=$1`, [e2.entry.id]);
}
{
const e = (await q.createEntry(userCompanyId, C.A, 'all-fields', 'check@test')).entry;
const list = await q.listEntries(userCompanyId);
const f = list.find(r => r.id === e.id);
log(f && f.value_cidr === cidr(C.A) && f.comment === 'all-fields' && f.created_by === 'check@test' && f.deleted_at === null,
'S20.3: все поля записи корректны');
await pool.query(`UPDATE whitelist_entries SET deleted_at=NOW() WHERE id=$1`, [e.id]);
}
// ══════════════════════════════════════════════════════════════════════════════
// S21. ADMIN CROSS-COMPANY
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S21. Admin cross-company ──');
{
const sup = require('supertest'); const req = sup(app);
const r1 = await req.get('/api/v1/entries').set('Authorization', 'Bearer ' + tokenAdmin);
log(r1.status === 200, 'S21.1: admin GET /entries → 200');
const r2 = await req.post('/api/v1/entries?company=' + userCompanyId)
.set('Authorization', 'Bearer ' + tokenAdmin).send({ value: C.A, comment: 'x' });
log(r2.status === 201, 'S21.2: admin POST /entries?company= → 201');
const eid = r2.body?.entry?.id;
if (eid) {
const r3 = await req.patch('/api/v1/entries/' + eid + '?company=' + userCompanyId)
.set('Authorization', 'Bearer ' + tokenAdmin).send({ value: C.B, comment: 'y' });
log(r3.status === 200, 'S21.3: admin PATCH чужой → 200');
const r4 = await req.delete('/api/v1/entries/' + eid + '?company=' + userCompanyId)
.set('Authorization', 'Bearer ' + tokenAdmin);
log([200, 204].includes(r4.status), 'S21.4: admin DELETE чужой → 200/204');
}
const r5 = await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
.set('Authorization', 'Bearer ' + tokenAdmin).send({ limit: 25 });
log(r5.status === 200, 'S21.5: admin setLimit чужой → 200');
await q.setLimit(userCompanyId, null);
}
// ══════════════════════════════════════════════════════════════════════════════
// S22. WEIRD CIDR INPUT
// ══════════════════════════════════════════════════════════════════════════════
console.log('\n── S22. Weird CIDR input ──');
const weird = [
[' 8.8.8.8 ', true, 'trim spaces'],
['8.8.8.8/24 ', true, 'space after mask'],
['\t8.8.8.8\t', true, 'tabs'],
['8.8.8.8\n', true, 'newline'],
['8.8.8.8/ 24', false, 'space before mask'],
['8.8.8.8 /24', false, 'space before slash'],
['8.8.8.8/24/32', false, 'double slash'],
['8.8.8.8/', false, 'slash no mask'],
['/24', false, 'only mask'],
['8.8.8.8/33', false, '/33'],
['8.8.8.8/-1', false, 'negative mask'],
['8.8.8.8/abc', false, 'alpha mask'],
['8.8.8.8/24abc', false, '24abc'],
['8.8.8.8.8', false, '5 octets'],
['8.8.8', false, '3 octets'],
['256.0.0.1', false, '256'],
['-1.8.8.8', false, 'negative octet'],
['::1', false, 'IPv6'],
['', false, 'empty'],
[' ', false, 'whitespace only'],
];
for (const [input, ok, desc] of weird) {
try { validate(input); log(ok, `S22: "${input}" (${desc}) — ${ok ? 'ok' : 'UNEXPECTED'}`); }
catch (e) { log(!ok, `S22: "${input}" (${desc}) — rejected`); }
}
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);
});