test: stress tests expanded to 191 (concurrency, boundary IPs, CIDR masks, auth attacks, unicode, session, massive parallel)
This commit is contained in:
+578
-1
@@ -441,8 +441,585 @@ async function cleanupAll() {
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// CLEANUP & RESULTS
|
||||
// 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.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 быстрых запросов к /exp (ограничен exportLimiter=20/мин)
|
||||
{
|
||||
const supertest = require('supertest');
|
||||
const req = supertest(app);
|
||||
const results = await Promise.all(
|
||||
Array(10).fill(0).map(() => req.get('/exp'))
|
||||
);
|
||||
const all200 = results.every(r => r.status === 200);
|
||||
log(all200, 'S14.1: 10× GET /exp без авторизации → все 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: /exp с пустой БД
|
||||
{
|
||||
const supertest = require('supertest');
|
||||
const req = supertest(app);
|
||||
const r = await req.get('/exp');
|
||||
log(r.status === 200, 'S15.1: /exp → 200');
|
||||
log(r.headers['content-type']?.includes('text/plain'), 'S15.2: /exp Content-Type text/plain');
|
||||
}
|
||||
|
||||
// S15.3: /exp после добавления записи
|
||||
{
|
||||
await q.createEntry(userCompanyId, C.A, 'export-test', 'stress@test');
|
||||
const supertest = require('supertest');
|
||||
const req = supertest(app);
|
||||
const r = await req.get('/exp');
|
||||
log(r.status === 200 && r.text.includes(cidr(C.A)), 'S15.3: /exp содержит добавленный 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.1–S18.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();
|
||||
|
||||
Reference in New Issue
Block a user