From 1e330a4395296e9496e0f691ae125e7d12bdefb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 30 May 2026 17:18:56 +0300 Subject: [PATCH] test: IPv6, boundary CIDR, race condition, rate-limiter (49/49 pass) --- tests/integration.js | 155 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 140 insertions(+), 15 deletions(-) diff --git a/tests/integration.js b/tests/integration.js index 38952d1..18ac7cf 100644 --- a/tests/integration.js +++ b/tests/integration.js @@ -543,22 +543,8 @@ async function cleanup() { } // ───────────────────────────────────────────────────────────────────────────── - // 17. Rate-limiter — превысить mutationLimiter (30 POST/min) + // 17. Rate-limiter — перенесён в конец, после всех тестов использующих userAgent // ───────────────────────────────────────────────────────────────────────────── - console.log('\n── 17. Rate-limiter ──'); - - { - // Отправляем 35 POST /add подряд с заведомо невалидным CIDR (быстро) - // Rate-limiter (30/min) должен сработать и вернуть 429 - let got429 = false; - for (let i = 0; i < 35; i++) { - const csrf = await getCsrfFromIndex(userAgent); - const res = await userAgent.post('/add').type('form') - .send({ value: 'not-an-ip', comment: `rate ${i}`, _csrf: csrf }); - if (res.status === 429) { got429 = true; break; } - } - log(got429, 'rate-limiter: POST /add → 429 после 30 запросов'); - } // ───────────────────────────────────────────────────────────────────────────── // 18. IDOR — user пытается добавить запись в чужую компанию через company_id @@ -637,6 +623,145 @@ async function cleanup() { log(res.status === 200, 'admin: GET /audit → 200', `status=${res.status}`); } + // ───────────────────────────────────────────────────────────────────────────── + // 21. IPv6 адреса — валидация + // ───────────────────────────────────────────────────────────────────────────── + console.log('\n── 21. IPv6 ──'); + + { + const csrf = await getCsrfFromIndex(userAgent); + const res = await userAgent.post('/add').type('form') + .send({ value: '2001:db8::1', comment: 'ipv6 test', _csrf: csrf }); + const err = getRedirectParam(res, 'error'); + const msg = getRedirectParam(res, 'message'); + // Либо принимает IPv6, либо отклоняет как несуппортируемый формат + log(res.status === 302, 'user: IPv6 → redirect (не падает 500)', `status=${res.status} msg=${msg} err=${err}`); + + // Чистим если добавился + await pool.query( + `UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'test-cleanup' + WHERE value_cidr LIKE '%2001:db8%' AND deleted_at IS NULL` + ); + } + + // ───────────────────────────────────────────────────────────────────────────── + // 22. Граничные CIDR — /0 и /32 + // ───────────────────────────────────────────────────────────────────────────── + console.log('\n── 22. Граничные CIDR ──'); + + { + // /0 — весь интернет, должно быть отклонено или принято + const csrf = await getCsrfFromIndex(userAgent); + const res = await userAgent.post('/add').type('form') + .send({ value: '0.0.0.0/0', comment: 'весь интернет', _csrf: csrf }); + const err = getRedirectParam(res, 'error'); + log(res.status === 302, 'user: 0.0.0.0/0 → redirect (не 500)', `err=${err}`); + // Чистим + await pool.query( + `UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'test-cleanup' + WHERE value_cidr = '0.0.0.0/0' AND deleted_at IS NULL` + ); + } + + { + // /32 с host-битами — нормализация не нужна, должен добавиться как есть + const csrf = await getCsrfFromIndex(userAgent); + const res = await userAgent.post('/add').type('form') + .send({ value: '5.5.5.5/32', comment: 'slash32', _csrf: csrf }); + const msg = getRedirectParam(res, 'message'); + const err = getRedirectParam(res, 'error'); + log(!err && res.status === 302, 'user: 5.5.5.5/32 → добавлено', `msg=${msg} err=${err}`); + // Чистим + await pool.query( + `UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'test-cleanup' + WHERE value_cidr = '5.5.5.5/32' AND deleted_at IS NULL` + ); + } + + // ───────────────────────────────────────────────────────────────────────────── + // 24. Race condition — одновременные запросы на /add при лимите + // (используем свежий агент, не отравленный rate-limit) + // ───────────────────────────────────────────────────────────────────────────── + console.log('\n── 24. Race condition: лимит ──'); + + { + const { MOCK_USERS } = require('../src/config'); + const testUser = MOCK_USERS.find(u => u.id === 'test'); + const uRes = await pool.query( + `SELECT id FROM companies WHERE client_id = $1 LIMIT 1`, [testUser?.clientId] + ); + const userCompId = uRes.rows[0]?.id; + + if (userCompId) { + // Свежий агент — не отравлен rate-limiter + const raceAgent = await loginAs(app, 'test'); + + const cntRes = await pool.query( + `SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL`, + [userCompId] + ); + const count = cntRes.rows[0].c; + const raceLimit = count + 1; + + const csrf = await getCsrfFromIndex(adminAgent); + await adminAgent.post(`/admin/limit/${userCompId}`).type('form') + .send({ limit: String(raceLimit), _csrf: csrf }); + + // 5 параллельных запросов — только 1 должен пройти + const raceCidrs = ['11.11.11.1', '11.11.11.2', '11.11.11.3', '11.11.11.4', '11.11.11.5']; + const results = await Promise.all(raceCidrs.map(async (cidr) => { + const c = await getCsrfFromIndex(raceAgent); + const r = await raceAgent.post('/add').type('form') + .send({ value: cidr, comment: 'race', _csrf: c }); + const st = r.status; + return { cidr, status: st, err: getRedirectParam(r, 'error'), msg: getRedirectParam(r, 'message') }; + })); + + // Считаем фактически добавленные в БД + const dbRes = await pool.query( + `SELECT COUNT(*)::int AS c FROM whitelist_entries + WHERE company_id = $1 AND value_cidr IN ('11.11.11.1/32','11.11.11.2/32','11.11.11.3/32','11.11.11.4/32','11.11.11.5/32') + AND deleted_at IS NULL`, [userCompId] + ); + const addedInDb = dbRes.rows[0].c; + log(addedInDb <= 1, 'race: в БД не более 1 записи (FOR UPDATE работает)', + `added=${addedInDb} results=${JSON.stringify(results.map(r => ({cidr: r.cidr, err: r.err?.slice(0,20)})))}`); + + // Сброс лимита + const csrf2 = await getCsrfFromIndex(adminAgent); + await adminAgent.post(`/admin/limit/${userCompId}`).type('form') + .send({ limit: '', _csrf: csrf2 }); + + // Чистим + await pool.query( + `UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'test-cleanup' + WHERE value_cidr IN ('11.11.11.1/32','11.11.11.2/32','11.11.11.3/32','11.11.11.4/32','11.11.11.5/32') + AND deleted_at IS NULL` + ); + } else { + log(false, 'race: нет компании user'); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // 25. Rate-limiter — превысить mutationLimiter (30 POST/min) + // Этот тест ПОСЛЕДНИЙ — исчерпывает rate-limit для userAgent + // ───────────────────────────────────────────────────────────────────────────── + console.log('\n── 25. Rate-limiter ──'); + + { + // Свежий агент — чтобы не зависеть от состояния userAgent + const rateAgent = await loginAs(app, 'test'); + let got429 = false; + for (let i = 0; i < 35; i++) { + const csrf = await getCsrfFromIndex(rateAgent); + const res = await rateAgent.post('/add').type('form') + .send({ value: 'not-an-ip', comment: `rate ${i}`, _csrf: csrf }); + if (res.status === 429) { got429 = true; break; } + } + log(got429, 'rate-limiter: POST /add → 429 после 30 запросов'); + } + // ───────────────────────────────────────────────────────────────────────────── // Cleanup // ─────────────────────────────────────────────────────────────────────────────