diff --git a/tests/stress.js b/tests/stress.js index cd0585e..80e3280 100644 --- a/tests/stress.js +++ b/tests/stress.js @@ -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();