'use strict';

/**
 * tests/api.js — интеграционные тесты REST API /api/v1/* против реальной БД.
 *
 * Запуск: node tests/api.js
 * Требует: .env с данными БД, DEV_MODE=true.
 *
 * В отличие от tests/integration.js — тестирует API-слой напрямую
 * через Bearer JWT (без сессий, без CSRF, без HTML-форм).
 *
 * Разделы:
 *   A. Auth — no/invalid token, 401
 *   B. Entries CRUD — user: создать, читать, обновить, удалить
 *   C. Валидация — неверный CIDR, дубликат, пересечение, нет value
 *   D. Нормализация — 9.9.9.5/24 → 9.9.9.0/24
 *   E. Изоляция — user2 не видит/меняет записи user
 *   F. Export — text/plain, изоляция
 *   G. Admin /companies — список, лимит
 *   H. Admin /audit — журнал
 *   I. Лимит — enforcement через API
 *   J. Безопасность — XSS, длинный comment, пустой comment
 *   K. Граничные значения — /0, /32, /22, маска > 32, IPv6
 *   L. Admin — CRUD по чужой компании (?company=<id>)
 *   M. Admin ACL — user не может вызвать admin-эндпоинты
 */

require('dotenv').config();

const supertest = require('supertest');
const { pool }  = require('../src/db');

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 bearer(token) {
  return { Authorization: 'Bearer ' + token };
}

/** Получить body как JSON, независимо от Content-Type */
function json(res) {
  if (typeof res.body === 'object' && res.body !== null) return res.body;
  try { return JSON.parse(res.text); } catch { return {}; }
}

// ── Тестовые CIDR (публичные диапазоны, не попадающие в BLOCKED_RANGES) ────────
// BLOCKED_RANGES включает: 10/8, 172.16/12, 192.168/16, 100.64/10, 127/8,
// 169.254/16, 192.0.0/24, 192.0.2/24, 198.51.100/24, 203.0.113/24,
// 198.18/15, 224/4, 240/4, 255.255.255.255/32
const C = {
  A:       '8.8.4.1',       // /32 хост (Google DNS range — публичный)
  B:       '8.8.4.2',
  C:       '8.8.4.3',
  D:       '8.8.4.4',
  E:       '8.8.4.5',
  F:       '8.8.4.6',
  G:       '8.8.4.7',
  SUBNET:  '9.9.9.5/24',   // нормализуется в 9.9.9.0/24 (Quad9, публичный)
  OVERLAP: '9.9.9.100',    // пересекается с 9.9.9.0/24
  ADMIN:   '1.1.1.100',    // admin добавляет в компанию user (Cloudflare range)
  XSS:     '1.1.1.10',
  LIMIT:   '1.1.1.20',
  EXPORT:  '1.1.1.30',
  SLASH32: '1.2.0.1/32',   // явный /32
  SLASH22: '1.2.4.0/22',   // /22 подсеть (не пересекается с SLASH32)
  SLASH0:  '0.0.0.0/0',   // маска < /22 → ошибка валидации
  EMPTY_C: '1.1.1.40',
  LONG_C:  '1.1.1.50',
  NULL_C:  '1.1.1.60',
  BLOCKED: '192.168.1.1',  // заблокированный приватный диапазон → 400
  RACE1:   '8.8.5.1',
  RACE2:   '8.8.5.2',
  RACE3:   '8.8.5.3',
  RACE4:   '8.8.5.4',
  RACE5:   '8.8.5.5',
};

// CIDR как они хранятся в БД (с /32 для хостов)
function cidr(ip) {
  return ip.includes('/') ? ip : ip + '/32';
}

// Нормализованный CIDR (host-биты обнулены)
function normCidr(ipCidr) {
  // для 9.9.9.5/24 → 9.9.9.0/24
  if (ipCidr === C.SUBNET) return '9.9.9.0/24';
  return cidr(ipCidr);
}

// ── Cleanup ───────────────────────────────────────────────────────────────────
async function cleanup() {
  const allCidrs = Object.values(C)
    .filter(c => !['0.0.0.0/0', '192.168.1.1'].includes(c)) // нельзя добавить → не нужно чистить
    .map(c => c.includes('/') ? c : c + '/32');
  // Нормализованная копия для SUBNET
  allCidrs.push('9.9.9.0/24');
  // Явный /32 для SLASH32
  allCidrs.push('1.2.0.1/32');
  // /22
  allCidrs.push('1.2.4.0/22');

  for (const c of allCidrs) {
    await pool.query(
      `UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'api-test-cleanup'
       WHERE value_cidr = $1 AND deleted_at IS NULL`, [c]
    );
  }
}

// ── Основной тест-раннер ──────────────────────────────────────────────────────

(async () => {
  console.log('\n══════════════════════════════════════════════════════');
  console.log('  API тесты /api/v1/* (Bearer JWT, реальная БД)');
  console.log('══════════════════════════════════════════════════════\n');

  // ── Запускаем сервер ─────────────────────────────────────────────────────────
  let app, auth;
  try {
    const mod = require('../server');
    app = await mod.start();
    // Инициализируем auth отдельно, чтобы выпустить токены
    auth = await require('../src/auth').initAuth();
  } catch (e) {
    console.error('FATAL: не удалось запустить сервер:', e.message);
    await pool.end();
    process.exit(1);
  }

  const req = supertest(app);

  // ── Токены для трёх пользователей ───────────────────────────────────────────
  const { MOCK_USERS } = require('../src/config');
  const uAdmin  = MOCK_USERS.find(u => u.id === 'admin');
  const uUser   = MOCK_USERS.find(u => u.id === 'alfa');
  const uUser2  = MOCK_USERS.find(u => u.id === 'beta');

  const tokenAdmin = auth.issueMockToken({ clientId: uAdmin.clientId, companyId: uAdmin.companyId, companyName: uAdmin.companyName, email: uAdmin.email });
  const tokenUser  = auth.issueMockToken({ clientId: uUser.clientId,  companyId: uUser.companyId,  companyName: uUser.companyName,  email: uUser.email  });
  const tokenUser2 = auth.issueMockToken({ clientId: uUser2.clientId, companyId: uUser2.companyId, companyName: uUser2.companyName, email: uUser2.email });

  log(!!tokenAdmin && !!tokenUser && !!tokenUser2, 'setup: выпустили токены для admin, user, user2');

  await cleanup();
  console.log('── Cleanup done ──\n');

  // ══════════════════════════════════════════════════════════════════════════════
  // A. Auth — отсутствие / невалидный токен
  // ══════════════════════════════════════════════════════════════════════════════
  console.log('── A. Auth ──');

  {
    const r = await req.get('/api/v1/entries');
    log(r.status === 401, 'A1: GET /entries без токена → 401', `status=${r.status}`);
    log(!!json(r).error, 'A2: тело содержит error-поле');
  }

  {
    const r = await req.get('/api/v1/entries').set('Authorization', 'Bearer broken.token.here');
    log(r.status === 401, 'A3: GET /entries с невалидным токеном → 401', `status=${r.status}`);
  }

  {
    const r = await req.get('/api/v1/entries').set('Authorization', 'Basic dXNlcjpwYXNz');
    log(r.status === 401, 'A4: Basic auth → 401 (ожидается Bearer)', `status=${r.status}`);
  }

  {
    const r = await req.post('/api/v1/entries').set(bearer(tokenUser));
    // Нет body — 400, но не 401
    log(r.status !== 401, 'A5: POST /entries с валидным токеном → не 401', `status=${r.status}`);
  }

  // ══════════════════════════════════════════════════════════════════════════════
  // B. Entries CRUD — базовый цикл
  // ══════════════════════════════════════════════════════════════════════════════
  console.log('\n── B. Entries CRUD ──');

  // B1: GET — пустой список (до добавления)
  {
    const r = await req.get('/api/v1/entries').set(bearer(tokenUser));
    log(r.status === 200, 'B1: GET /entries → 200', `status=${r.status}`);
    const b = json(r);
    log(Array.isArray(b.entries), 'B2: тело содержит entries[]');
    log(typeof b.limit === 'number', 'B3: тело содержит limit (число)', `limit=${b.limit}`);
    log(typeof b.used === 'number',  'B4: тело содержит used (число)',  `used=${b.used}`);
    log(b.used === b.entries.length, 'B5: used === entries.length');
  }

  // B6: POST — создать запись
  let entryId;
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser))
      .send({ value: C.A, comment: 'api-тест A' });
    log(r.status === 201, 'B6: POST /entries → 201', `status=${r.status} body=${JSON.stringify(json(r)).slice(0,120)}`);
    const b = json(r);
    if (r.status === 201 && b.entry) {
      log(!!b.entry.id, 'B7: ответ содержит entry.id');
      log(b.entry.value_cidr === cidr(C.A), 'B8: entry.value_cidr верный', `got=${b.entry.value_cidr}`);
      log(b.entry.comment === 'api-тест A', 'B9: entry.comment сохранён');
      log(b.wasNormalized === false, 'B10: wasNormalized=false для хост-адреса', `got=${b.wasNormalized}`);
      entryId = b.entry.id;
    } else {
      log(false, 'B7: ответ содержит entry.id'); log(false, 'B8: entry.value_cidr верный');
      log(false, 'B9: entry.comment сохранён'); log(false, 'B10: wasNormalized=false');
    }
  }

  // B11: GET — запись появилась в списке
  {
    const r = await req.get('/api/v1/entries').set(bearer(tokenUser));
    const b = json(r);
    const found = b.entries?.find(e => e.id === entryId);
    log(!!found, 'B11: созданная запись появилась в GET /entries');
    log(b.used >= 1, 'B12: used >= 1 после добавления', `used=${b.used}`);
  }

  // B13: PATCH — обновить запись
  if (entryId) {
    const r = await req.patch('/api/v1/entries/' + entryId)
      .set(bearer(tokenUser))
      .send({ value: C.B, comment: 'обновлено' });
    log(r.status === 200, 'B13: PATCH /entries/:id → 200', `status=${r.status}`);
    const b = json(r);
    log(b.entry?.value_cidr === cidr(C.B), 'B14: value_cidr обновлён', `got=${b.entry?.value_cidr}`);
    log(b.entry?.comment === 'обновлено', 'B15: comment обновлён');
  } else { log(false,'B13: PATCH'); log(false,'B14: value_cidr'); log(false,'B15: comment'); }

  // B16: DELETE — удалить запись
  {
    const r = await req.delete('/api/v1/entries/' + entryId).set(bearer(tokenUser));
    log([200, 204].includes(r.status), 'B16: DELETE /entries/:id → 200/204', `status=${r.status}`);
  }

  // B17: запись не видна после удаления
  {
    const r = await req.get('/api/v1/entries').set(bearer(tokenUser));
    const b = json(r);
    const found = b.entries?.find(e => e.id === entryId);
    log(!found, 'B17: удалённая запись не в списке GET /entries');
  }

  // B18: soft delete — deleted_at выставлен в БД
  if (entryId) {
    const row = await pool.query('SELECT deleted_at FROM whitelist_entries WHERE id = $1', [entryId]);
    log(!!row.rows[0]?.deleted_at, 'B18: soft delete — deleted_at выставлен в БД');
  }

  // B19: DELETE несуществующей (уже удалена) → 404
  {
    const r = await req.delete('/api/v1/entries/' + entryId).set(bearer(tokenUser));
    log(r.status === 404, 'B19: повторный DELETE → 404', `status=${r.status}`);
  }

  // B20: PATCH несуществующей → 404
  {
    const r = await req.patch('/api/v1/entries/' + entryId)
      .set(bearer(tokenUser))
      .send({ value: C.C, comment: '' });
    log(r.status === 404, 'B20: PATCH удалённой → 404', `status=${r.status}`);
  }

  // ══════════════════════════════════════════════════════════════════════════════
  // C. Валидация
  // ══════════════════════════════════════════════════════════════════════════════
  console.log('\n── C. Валидация ──');

  // C1: нет value → 400
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser))
      .send({ comment: 'нет ip' });
    log(r.status === 400, 'C1: POST без value → 400', `status=${r.status}`);
  }

  // C2: пустой value → 400 или ошибка валидации
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser))
      .send({ value: '', comment: '' });
    log([400, 409, 422].includes(r.status), 'C2: POST с value="" → не 201', `status=${r.status}`);
  }

  // C3: текст вместо IP → 400/409
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser))
      .send({ value: 'not-an-ip', comment: '' });
    log([400, 409, 422].includes(r.status), 'C3: POST "not-an-ip" → не 201', `status=${r.status}`);
    log(!!json(r).error, 'C4: тело содержит error');
  }

  // C5: октет > 255
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser))
      .send({ value: '300.0.0.1' });
    log([400, 409, 422].includes(r.status), 'C5: 300.0.0.1 → не 201', `status=${r.status}`);
  }

  // C6: маска > 32
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser))
      .send({ value: '10.0.0.1/33' });
    log([400, 409, 422].includes(r.status), 'C6: /33 → не 201', `status=${r.status}`);
  }

  // C7: маска < 0 (negative)
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser))
      .send({ value: '10.0.0.1/-1' });
    log([400, 409, 422].includes(r.status), 'C7: /-1 → не 201', `status=${r.status}`);
  }

  // ══════════════════════════════════════════════════════════════════════════════
  // D. Нормализация CIDR
  // ══════════════════════════════════════════════════════════════════════════════
  console.log('\n── D. Нормализация ──');

  // D1: 9.9.9.5/24 → 9.9.9.0/24
  let subnetEntryId;
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser))
      .send({ value: C.SUBNET, comment: 'нормализация' });
    log(r.status === 201, 'D1: POST 9.9.9.5/24 → 201', `status=${r.status} body=${JSON.stringify(json(r)).slice(0,120)}`);
    const b = json(r);
    log(b.wasNormalized === true, 'D2: wasNormalized=true', `got=${b.wasNormalized}`);
    log(b.entry?.value_cidr === '9.9.9.0/24', 'D3: нормализован → 9.9.9.0/24', `got=${b.entry?.value_cidr}`);
    subnetEntryId = b.entry?.id;
  }

  // D4: дубликат (сам себя) → 409
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser))
      .send({ value: C.SUBNET, comment: 'дубликат' });
    log(r.status === 409, 'D4: дубликат нормализованного → 409', `status=${r.status}`);
    log(json(r).error?.includes('уже существует'), 'D5: error содержит "уже существует"');
  }

  // D6: пересечение — 9.9.9.100 ⊂ 9.9.9.0/24 → 409
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser))
      .send({ value: C.OVERLAP, comment: 'пересечение' });
    log(r.status === 409, 'D6: пересекающийся CIDR → 409', `status=${r.status}`);
    log(json(r).error?.includes('Пересечение'), 'D7: error содержит "Пересечение"');
  }

  // Удаляем subnet для чистоты
  if (subnetEntryId) {
    await req.delete('/api/v1/entries/' + subnetEntryId).set(bearer(tokenUser));
  }

  // ══════════════════════════════════════════════════════════════════════════════
  // E. Изоляция между компаниями
  // ══════════════════════════════════════════════════════════════════════════════
  console.log('\n── E. Изоляция ──');

  // Добавляем запись под user
  let isoEntryId;
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser))
      .send({ value: C.C, comment: 'isolation test' });
    isoEntryId = json(r).entry?.id;
    log(r.status === 201 && !!isoEntryId, 'E1: user создал запись для теста изоляции');
  }

  // E2: user2 не видит записи user в GET /entries
  {
    const r = await req.get('/api/v1/entries').set(bearer(tokenUser2));
    const b = json(r);
    const found = b.entries?.find(e => e.id === isoEntryId);
    log(!found, 'E2: user2 не видит запись user в своём GET /entries');
  }

  // E3: user2 не может PATCH чужой записи → 404 (запись не найдена в его компании)
  {
    const r = await req.patch('/api/v1/entries/' + isoEntryId)
      .set(bearer(tokenUser2))
      .send({ value: C.D, comment: 'IDOR' });
    log(r.status === 404, 'E3: user2 PATCH чужой записи → 404', `status=${r.status}`);
  }

  // E4: user2 не может DELETE чужой записи → 404
  {
    const r = await req.delete('/api/v1/entries/' + isoEntryId).set(bearer(tokenUser2));
    log(r.status === 404, 'E4: user2 DELETE чужой записи → 404', `status=${r.status}`);
  }

  // Убедимся что запись user цела
  {
    const row = await pool.query('SELECT deleted_at FROM whitelist_entries WHERE id = $1', [isoEntryId]);
    log(!row.rows[0]?.deleted_at, 'E5: запись user жива после атак user2');
  }

  // Чистим
  await req.delete('/api/v1/entries/' + isoEntryId).set(bearer(tokenUser));

  // ══════════════════════════════════════════════════════════════════════════════
  // F. Export
  // ══════════════════════════════════════════════════════════════════════════════
  console.log('\n── F. Export ──');

  // Добавляем запись для export-теста
  {
    await req.post('/api/v1/entries').set(bearer(tokenUser)).send({ value: C.EXPORT, comment: 'export' });
  }

  // F1: GET /api/v1/entries/export → 200 text/plain
  {
    const r = await req.get('/api/v1/entries/export').set(bearer(tokenUser));
    log(r.status === 200, 'F1: GET /export → 200', `status=${r.status}`);
    log(r.headers['content-type']?.includes('text/plain'), 'F2: Content-Type: text/plain');
    log(r.text.includes(cidr(C.EXPORT)), 'F3: экспорт содержит добавленный CIDR', `text=${r.text.slice(0,100)}`);
  }

  // F4: user export не содержит записи user2
  {
    await req.post('/api/v1/entries').set(bearer(tokenUser2)).send({ value: C.D, comment: 'u2-export' });
    const rUser  = await req.get('/api/v1/entries/export').set(bearer(tokenUser));
    const rUser2 = await req.get('/api/v1/entries/export').set(bearer(tokenUser2));
    log(!rUser.text.includes(cidr(C.D)), 'F4: экспорт user не содержит CIDR user2');
    log(!rUser2.text.includes(cidr(C.EXPORT)), 'F5: экспорт user2 не содержит CIDR user');
    await req.delete('/api/v1/entries/' + (
      (await pool.query(`SELECT id FROM whitelist_entries WHERE value_cidr=$1 AND deleted_at IS NULL`, [cidr(C.D)])).rows[0]?.id
    )).set(bearer(tokenUser2));
  }

  // F6: удалённый CIDR не появляется в export
  {
    const exportId = (await pool.query(
      `SELECT id FROM whitelist_entries WHERE value_cidr=$1 AND deleted_at IS NULL`, [cidr(C.EXPORT)]
    )).rows[0]?.id;
    if (exportId) {
      await req.delete('/api/v1/entries/' + exportId).set(bearer(tokenUser));
      const r = await req.get('/api/v1/entries/export').set(bearer(tokenUser));
      log(!r.text.includes(cidr(C.EXPORT)), 'F6: удалённый CIDR не в export');
    } else {
      log(false, 'F6: не нашли запись для удаления');
    }
  }

  // F7: export без токена → 401
  {
    const r = await req.get('/api/v1/entries/export');
    log(r.status === 401, 'F7: export без токена → 401', `status=${r.status}`);
  }

  // ══════════════════════════════════════════════════════════════════════════════
  // G. Admin /companies
  // ══════════════════════════════════════════════════════════════════════════════
  console.log('\n── G. Admin /companies ──');

  // G1: admin получает список компаний
  let userCompanyId;
  {
    const r = await req.get('/api/v1/companies').set(bearer(tokenAdmin));
    log(r.status === 200, 'G1: GET /companies (admin) → 200', `status=${r.status}`);
    const b = json(r);
    log(Array.isArray(b.companies), 'G2: тело содержит companies[]');
    log(b.companies?.length >= 1, 'G3: хотя бы одна компания', `count=${b.companies?.length}`);
    const comp = b.companies?.find(c => c.client_id === uUser.clientId);
    log(!!comp, 'G4: компания user присутствует в списке', `clientId=${uUser.clientId}`);
    userCompanyId = comp?.id;
  }

  // G5: user не может получить список компаний → 403
  {
    const r = await req.get('/api/v1/companies').set(bearer(tokenUser));
    log(r.status === 403, 'G5: GET /companies (user) → 403', `status=${r.status}`);
  }

  // G6: установить лимит
  {
    const r = await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
      .set(bearer(tokenAdmin))
      .send({ limit: 50 });
    log(r.status === 200, 'G6: PATCH /companies/:id/limit → 200', `status=${r.status}`);
    log(json(r).limit === 50, 'G7: лимит в ответе = 50', `got=${json(r).limit}`);
  }

  // G8: проверить лимит применился
  {
    const r = await req.get('/api/v1/entries').set(bearer(tokenUser));
    log(json(r).limit === 50, 'G8: GET /entries возвращает обновлённый limit=50', `got=${json(r).limit}`);
  }

  // G9: невалидный лимит — отрицательный → 400
  {
    const r = await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
      .set(bearer(tokenAdmin))
      .send({ limit: -1 });
    log(r.status === 400, 'G9: limit=-1 → 400', `status=${r.status}`);
  }

  // G10: невалидный лимит — строка → 400
  {
    const r = await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
      .set(bearer(tokenAdmin))
      .send({ limit: 'abc' });
    log(r.status === 400, 'G10: limit="abc" → 400', `status=${r.status}`);
  }

  // G11: user не может менять лимиты → 403
  {
    const r = await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
      .set(bearer(tokenUser))
      .send({ limit: 5 });
    log(r.status === 403, 'G11: PATCH /limit (user) → 403', `status=${r.status}`);
  }

  // Сбрасываем лимит обратно (NULL = дефолт)
  await pool.query(`UPDATE companies SET custom_limit = NULL WHERE id = $1`, [userCompanyId]);

  // ══════════════════════════════════════════════════════════════════════════════
  // H. Admin /audit
  // ══════════════════════════════════════════════════════════════════════════════
  console.log('\n── H. Admin /audit ──');

  // H1: admin получает журнал
  {
    const r = await req.get('/api/v1/audit').set(bearer(tokenAdmin));
    log(r.status === 200, 'H1: GET /audit (admin) → 200', `status=${r.status}`);
    const b = json(r);
    log(Array.isArray(b.rows), 'H2: тело содержит rows[]');
    log(Array.isArray(b.companies), 'H3: тело содержит companies[]');
  }

  // H4: admin фильтрует по компании
  {
    const r = await req.get('/api/v1/audit?company=' + userCompanyId).set(bearer(tokenAdmin));
    log(r.status === 200, 'H4: GET /audit?company=<id> → 200', `status=${r.status}`);
    const rows = json(r).rows || [];
    const allForCompany = rows.every(row => row.company_id === userCompanyId);
    log(allForCompany || rows.length === 0, 'H5: все записи аудита для указанной компании');
  }

  // H6: user не видит аудит → 403
  {
    const r = await req.get('/api/v1/audit').set(bearer(tokenUser));
    log(r.status === 403, 'H6: GET /audit (user) → 403', `status=${r.status}`);
  }

  // H7: операции записаны в audit_log
  {
    const rows = (await pool.query(
      `SELECT action FROM audit_log WHERE company_id = $1 ORDER BY created_at DESC LIMIT 20`, [userCompanyId]
    )).rows;
    const actions = rows.map(r => r.action);
    log(actions.some(a => /create/i.test(a)), 'H7: CREATE записан в audit_log', `actions=${JSON.stringify(actions.slice(0,5))}`);
    log(actions.some(a => /delete/i.test(a)), 'H8: DELETE записан в audit_log');
  }

  // ══════════════════════════════════════════════════════════════════════════════
  // I. Лимит — enforcement
  // ══════════════════════════════════════════════════════════════════════════════
  console.log('\n── I. Лимит enforcement ──');

  // I1: лимит = 0 → любая вставка → 409
  {
    await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
      .set(bearer(tokenAdmin)).send({ limit: 0 });

    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser)).send({ value: C.LIMIT, comment: 'лимит=0' });
    log(r.status === 409, 'I1: лимит=0, POST /entries → 409', `status=${r.status}`);
    log(json(r).error?.includes('Лимит'), 'I2: error содержит "Лимит"');

    await pool.query(`UPDATE companies SET custom_limit = NULL WHERE id = $1`, [userCompanyId]);
  }

  // I3: лимит = currentCount → новая запись → 409
  {
    // Считаем текущее количество
    const cnt = (await pool.query(
      `SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL`,
      [userCompanyId]
    )).rows[0].c;

    await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
      .set(bearer(tokenAdmin)).send({ limit: cnt });

    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser)).send({ value: C.LIMIT, comment: 'лимит=count' });
    log(r.status === 409, 'I3: лимит=currentCount, POST → 409', `status=${r.status} cnt=${cnt}`);

    await pool.query(`UPDATE companies SET custom_limit = NULL WHERE id = $1`, [userCompanyId]);
  }

  // I4: снижение лимита не удаляет существующие записи
  {
    // Добавим одну запись
    const addR = await req.post('/api/v1/entries')
      .set(bearer(tokenUser)).send({ value: C.E, comment: 'limit-shrink' });
    const eid = json(addR).entry?.id;

    // Ставим лимит = 0 (ниже существующего количества)
    await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
      .set(bearer(tokenAdmin)).send({ limit: 0 });

    // Запись всё ещё видна в списке
    const listR = await req.get('/api/v1/entries').set(bearer(tokenUser));
    const found = json(listR).entries?.find(e => e.id === eid);
    log(!!found, 'I4: снижение лимита не удаляет существующие записи');

    // Сброс + чистка
    await pool.query(`UPDATE companies SET custom_limit = NULL WHERE id = $1`, [userCompanyId]);
    if (eid) await req.delete('/api/v1/entries/' + eid).set(bearer(tokenUser));
  }

  // ══════════════════════════════════════════════════════════════════════════════
  // J. Безопасность — XSS, длинный и пустой comment
  // ══════════════════════════════════════════════════════════════════════════════
  console.log('\n── J. Безопасность ──');

  // J1: XSS в comment — принимается, но в API возвращается без исполнения
  {
    const xssPayload = '<script>alert(1)</script>';
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser)).send({ value: C.XSS, comment: xssPayload });
    // API принимает (не валидирует comment на XSS — это задача шаблона)
    if (r.status === 201) {
      const b = json(r);
      // В JSON-ответе скрипт не должен быть исполнен — это просто строка
      log(b.entry?.comment === xssPayload, 'J1: XSS в comment сохранён как plain string');
      await req.delete('/api/v1/entries/' + b.entry.id).set(bearer(tokenUser));
    } else {
      log(r.status !== 500, 'J1: XSS в comment → не 500', `status=${r.status}`);
    }
  }

  // J2: пустой comment → 201 (comment необязателен)
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser)).send({ value: C.EMPTY_C, comment: '' });
    log(r.status === 201, 'J2: пустой comment → 201', `status=${r.status}`);
    const eid = json(r).entry?.id;
    if (eid) await req.delete('/api/v1/entries/' + eid).set(bearer(tokenUser));
  }

  // J3: очень длинный comment → 400 (> 255 символов)
  {
    const longComment = 'X'.repeat(10000);
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser)).send({ value: C.LONG_C, comment: longComment });
    log(r.status === 400, 'J3: длинный comment (10k) → 400', `status=${r.status}`);
    log(json(r).error?.includes('длинный') || json(r).error?.includes('255'), 'J3b: error указывает на длину', `err=${json(r).error}`);
  }

  // J4: null comment → не 500
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser)).send({ value: C.NULL_C, comment: null });
    log(r.status !== 500, 'J4: null comment → не 500', `status=${r.status}`);
    const eid = json(r).entry?.id;
    if (eid) await req.delete('/api/v1/entries/' + eid).set(bearer(tokenUser));
  }

  // J5: SQL injection в value → 400 (валидатор отклоняет как невалидный IP)
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser)).send({ value: "1.2.3.4'; DROP TABLE companies;--", comment: '' });
    log([400, 409, 422].includes(r.status), 'J5: SQL injection в value → не 201 и не 500', `status=${r.status}`);
  }

  // J6: заблокированный диапазон → 400 (не 500)
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser)).send({ value: C.BLOCKED, comment: 'blocked' });
    log(r.status === 400, 'J6: приватный IP (BLOCKED_RANGES) → 400', `status=${r.status}`);
    log(json(r).error?.includes('запрещённым'), 'J7: error содержит "запрещённым"', `err=${json(r).error}`);
  }

  // ══════════════════════════════════════════════════════════════════════════════
  // K. Граничные значения CIDR
  // ══════════════════════════════════════════════════════════════════════════════
  console.log('\n── K. Граничные CIDR ──');

  // K1: /32 — одиночный хост без маски
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser)).send({ value: C.SLASH32, comment: '/32 без маски' });
    log(r.status === 201, 'K1: хост без /32 → 201', `status=${r.status}`);
    log(json(r).entry?.value_cidr === C.SLASH32, 'K2: /32 сохранён как есть', `got=${json(r).entry?.value_cidr}`);
    const eid = json(r).entry?.id;
    if (eid) await req.delete('/api/v1/entries/' + eid).set(bearer(tokenUser));
  }

  // K3: /22 — подсеть, должна приниматься
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser)).send({ value: C.SLASH22, comment: '/22' });
    log([201, 409].includes(r.status), 'K3: /22 → принят или 409 (пересечение)', `status=${r.status}`);
    const eid = json(r).entry?.id;
    if (eid) await req.delete('/api/v1/entries/' + eid).set(bearer(tokenUser));
  }

  // K4: 0.0.0.0/0 — маска /0 < /22 → 400
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser)).send({ value: C.SLASH0, comment: '0.0.0.0/0' });
    log(r.status === 400, 'K4: 0.0.0.0/0 (маска /0 < /22) → 400', `status=${r.status}`);
    log(json(r).error?.includes('Маска'), 'K4b: error содержит "Маска"', `err=${json(r).error}`);
  }

  // K5: IPv6 → отклоняется (только IPv4), 400
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser)).send({ value: '2001:db8::1', comment: 'ipv6' });
    log(r.status === 400, 'K5: IPv6 → 400', `status=${r.status}`);
    log(json(r).error?.includes('IPv6'), 'K5b: error содержит "IPv6"', `err=${json(r).error}`);
  }

  // K6: localhost (127.0.0.1) → заблокирован → 400
  {
    const r = await req.post('/api/v1/entries')
      .set(bearer(tokenUser)).send({ value: '127.0.0.1', comment: 'localhost' });
    log(r.status === 400, 'K6: 127.0.0.1 (заблокирован) → 400', `status=${r.status}`);
  }

  // ══════════════════════════════════════════════════════════════════════════════
  // L. Admin — CRUD по чужой компании (?company=<id>)
  // ══════════════════════════════════════════════════════════════════════════════
  console.log('\n── L. Admin CRUD по чужой компании ──');

  // L1: admin добавляет запись в компанию user
  let adminAddedId;
  {
    const r = await req.post('/api/v1/entries?company=' + userCompanyId)
      .set(bearer(tokenAdmin))
      .send({ value: C.ADMIN, comment: 'от admin' });
    log(r.status === 201, 'L1: admin POST /entries?company=<id> → 201', `status=${r.status}`);
    const b = json(r);
    // Запись должна оказаться в компании user, не admin
    adminAddedId = b.entry?.id;
    if (adminAddedId) {
      const row = await pool.query('SELECT company_id FROM whitelist_entries WHERE id = $1', [adminAddedId]);
      log(row.rows[0]?.company_id === userCompanyId, 'L2: запись сохранена в компанию user', `company_id=${row.rows[0]?.company_id}`);
    } else {
      log(false, 'L2: нет adminAddedId');
    }
  }

  // L3: admin видит запись через GET /entries?company=<id>
  {
    const r = await req.get('/api/v1/entries?company=' + userCompanyId).set(bearer(tokenAdmin));
    log(r.status === 200, 'L3: admin GET /entries?company=<id> → 200', `status=${r.status}`);
    const found = json(r).entries?.find(e => e.id === adminAddedId);
    log(!!found, 'L4: admin видит запись компании user', `adminAddedId=${adminAddedId}`);
  }

  // L5: admin обновляет запись в компании user
  {
    const r = await req.patch('/api/v1/entries/' + adminAddedId + '?company=' + userCompanyId)
      .set(bearer(tokenAdmin))
      .send({ value: C.G, comment: 'обновлено admin' });
    log(r.status === 200, 'L5: admin PATCH /entries/:id?company=<id> → 200', `status=${r.status}`);
  }

  // L6: admin удаляет запись из компании user
  {
    const r = await req.delete('/api/v1/entries/' + adminAddedId + '?company=' + userCompanyId)
      .set(bearer(tokenAdmin));
    log([200, 204].includes(r.status), 'L6: admin DELETE /entries/:id?company=<id> → 200/204', `status=${r.status}`);
  }

  // L7: user не может использовать ?company=<чужой> → запись попадает в свою компанию
  // Используем компанию user2 (создана в разделе F)
  {
    const user2Comp = (await pool.query(
      `SELECT id FROM companies WHERE client_id = $1`, [uUser2.clientId]
    )).rows[0]?.id;

    if (user2Comp) {
      const cntBefore = (await pool.query(
        `SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL`, [user2Comp]
      )).rows[0].c;

      const r = await req.post('/api/v1/entries?company=' + user2Comp)
        .set(bearer(tokenUser))
        .send({ value: C.D, comment: 'IDOR через ?company' });

      const cntAfter = (await pool.query(
        `SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL`, [user2Comp]
      )).rows[0].c;

      log(cntBefore === cntAfter, 'L7: user не смог добавить в компанию user2 через ?company=',
        `before=${cntBefore} after=${cntAfter}`);

      await pool.query(
        `UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'api-test-cleanup'
         WHERE value_cidr = $1 AND company_id = $2 AND deleted_at IS NULL`, [cidr(C.D), user2Comp]
      );
    } else {
      log(false, 'L7: не нашли компанию user2');
    }
  }

  // ══════════════════════════════════════════════════════════════════════════════
  // M. Admin ACL — user не вызывает admin-эндпоинты
  // ══════════════════════════════════════════════════════════════════════════════
  console.log('\n── M. Admin ACL ──');

  {
    const r = await req.get('/api/v1/companies').set(bearer(tokenUser));
    log(r.status === 403, 'M1: GET /companies (user) → 403', `status=${r.status}`);
  }

  {
    const r = await req.patch('/api/v1/companies/1/limit')
      .set(bearer(tokenUser)).send({ limit: 100 });
    log(r.status === 403, 'M2: PATCH /limit (user) → 403', `status=${r.status}`);
  }

  {
    const r = await req.get('/api/v1/audit').set(bearer(tokenUser));
    log(r.status === 403, 'M3: GET /audit (user) → 403', `status=${r.status}`);
  }

  {
    const r = await req.get('/api/v1/companies').set(bearer(tokenUser2));
    log(r.status === 403, 'M4: GET /companies (user2) → 403', `status=${r.status}`);
  }

  // ══════════════════════════════════════════════════════════════════════════════
  // N. Race condition — параллельные вставки при лимите
  // ══════════════════════════════════════════════════════════════════════════════
  console.log('\n── N. Race condition ──');

  {
    const cnt = (await pool.query(
      `SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL`,
      [userCompanyId]
    )).rows[0].c;

    // Лимит = текущее количество + 1 → только одна из 5 параллельных вставок пройдёт
    await req.patch('/api/v1/companies/' + userCompanyId + '/limit')
      .set(bearer(tokenAdmin)).send({ limit: cnt + 1 });

    const raceCidrs = [C.RACE1, C.RACE2, C.RACE3, C.RACE4, C.RACE5];
    const results = await Promise.all(
      raceCidrs.map(c => req.post('/api/v1/entries').set(bearer(tokenUser)).send({ value: c, comment: 'race' }))
    );

    const ok201 = results.filter(r => r.status === 201).length;
    const ok409 = results.filter(r => r.status === 409).length;

    log(ok201 <= 1, 'N1: race condition: не более 1 записи прошло', `201=${ok201} 409=${ok409}`);
    log(ok201 + ok409 === 5, 'N2: все 5 запросов получили 201 или 409', `results=${results.map(r => r.status).join(',')}`);

    // Сброс лимита
    await pool.query(`UPDATE companies SET custom_limit = NULL WHERE id = $1`, [userCompanyId]);

    // Чистка race-записей
    for (const c of raceCidrs) {
      await pool.query(
        `UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'api-test-cleanup'
         WHERE value_cidr = $1 AND deleted_at IS NULL`, [cidr(c)]
      );
    }
  }

  // ══════════════════════════════════════════════════════════════════════════════
  // O. Multi-company — токен с clientId через запятую
  // ══════════════════════════════════════════════════════════════════════════════
  console.log('\n── O. Multi-company ──');

  const uMulti = MOCK_USERS.find(u => u.id === 'multi');
  const tokenMulti = uMulti
    ? auth.issueMockToken({ clientId: uMulti.clientId, companyId: uMulti.companyId, companyName: uMulti.companyName, email: uMulti.email })
    : null;

  if (!tokenMulti) {
    log(false, 'O0: не найден multi-пользователь в MOCK_USERS');
  } else {
    log(!!tokenMulti, 'O0: выпущен токен multi (WZ88888, WZ77777)');

    // O1: GET /entries без ?client_id= → видит записи первой компании (WZ88888)
    // Сначала добавим запись в обе компании для проверки
    let multiEntry1, multiEntry2;

    // O1a: создаём запись в первой компании (WZ88888) через multi-токен
    {
      const r = await req.post('/api/v1/entries').set(bearer(tokenMulti))
        .send({ value: '4.4.4.1', comment: 'multi-company-1' });
      log(r.status === 201, 'O1a: multi POST → 201 (первая компания WZ88888)', `status=${r.status}`);
      multiEntry1 = json(r).entry?.id;
    }

    // O1b: создаём запись во ВТОРОЙ компании (WZ77777) через ?client_id=
    {
      const r = await req.post('/api/v1/entries?client_id=WZ77777').set(bearer(tokenMulti))
        .send({ value: '4.4.4.2', comment: 'multi-company-2' });
      log(r.status === 201, 'O1b: multi POST ?client_id=WZ77777 → 201', `status=${r.status}`);
      multiEntry2 = json(r).entry?.id;
    }

    // O2: GET без ?client_id= → только записи WZ88888 (не видит WZ77777)
    {
      const rDefault = await req.get('/api/v1/entries').set(bearer(tokenMulti));
      const entries = json(rDefault).entries || [];
      const hasE1 = entries.some(e => e.id === multiEntry1);
      const hasE2 = entries.some(e => e.id === multiEntry2);
      log(hasE1, 'O2a: GET default содержит запись WZ88888');
      log(!hasE2, 'O2b: GET default НЕ содержит запись WZ77777');
    }

    // O3: GET ?client_id=WZ77777 → только записи WZ77777
    {
      const rSwitched = await req.get('/api/v1/entries?client_id=WZ77777').set(bearer(tokenMulti));
      const entries = json(rSwitched).entries || [];
      const hasE1 = entries.some(e => e.id === multiEntry1);
      const hasE2 = entries.some(e => e.id === multiEntry2);
      log(!hasE1, 'O3a: GET ?client_id=WZ77777 НЕ содержит запись WZ88888');
      log(hasE2, 'O3b: GET ?client_id=WZ77777 содержит запись WZ77777');
    }

    // O4: PATCH через ?client_id= работает изолированно
    {
      const r = await req.patch('/api/v1/entries/' + multiEntry2 + '?client_id=WZ77777')
        .set(bearer(tokenMulti))
        .send({ value: '4.4.4.3', comment: 'patched-in-WZ77777' });
      log(r.status === 200, 'O4: multi PATCH ?client_id=WZ77777 → 200', `status=${r.status}`);
    }

    // O5: DELETE через ?client_id=
    {
      const r = await req.delete('/api/v1/entries/' + multiEntry2 + '?client_id=WZ77777')
        .set(bearer(tokenMulti));
      log([200, 204].includes(r.status), 'O5: multi DELETE ?client_id=WZ77777 → 200/204', `status=${r.status}`);
    }

    // O6: экспорт с ?client_id=WZ77777 не содержит запись WZ88888
    {
      // Создаём запись в WZ77777 для проверки экспорта
      const cr = await req.post('/api/v1/entries?client_id=WZ77777').set(bearer(tokenMulti))
        .send({ value: '4.4.5.1', comment: 'export-multi' });
      const exportEntryId = json(cr).entry?.id;

      const rWZ02 = await req.get('/api/v1/entries/export?client_id=WZ77777').set(bearer(tokenMulti));
      log(rWZ02.status === 200, 'O6a: export ?client_id=WZ77777 → 200', `status=${rWZ02.status}`);
      log(rWZ02.text.includes('4.4.5.1'), 'O6b: export содержит CIDR WZ77777');
      log(!rWZ02.text.includes('4.4.4.1'), 'O6c: export НЕ содержит CIDR WZ88888');

      // O6d: экспорт без ?client_id= → только WZ88888
      const rDefault = await req.get('/api/v1/entries/export').set(bearer(tokenMulti));
      log(rDefault.text.includes('4.4.4.1'), 'O6d: export default содержит WZ88888');
      log(!rDefault.text.includes('4.4.5.1'), 'O6e: export default НЕ содержит WZ77777');

      // Чистка
      if (exportEntryId) {
        await req.delete('/api/v1/entries/' + exportEntryId + '?client_id=WZ77777').set(bearer(tokenMulti));
      }
    }

    // O7: несуществующий ?client_id= → игнорируется, фоллбек на WZ88888 (безопасность)
    {
      const r = await req.get('/api/v1/entries?client_id=NONEXISTENT').set(bearer(tokenMulti));
      log(r.status === 200, 'O7: GET ?client_id=NONEXISTENT → 200 (игнор, фоллбек)', `status=${r.status}`);
      // NONEXISTENT не в allClientIds → фоллбек на WZ88888 → записи ЕСТЬ
      log(json(r).entries?.length > 0, 'O7b: фоллбек на WZ88888 — записи есть');
    }

    // O8: admin передаёт ?client_id= → использует свою компанию (игнор)
    {
      const r = await req.get('/api/v1/entries?client_id=WZ77777').set(bearer(tokenAdmin));
      log(r.status === 200, 'O8: admin GET ?client_id= → 200 (игнорирует)', `status=${r.status}`);
    }

    // Чистка записей multi
    if (multiEntry1) {
      await req.delete('/api/v1/entries/' + multiEntry1).set(bearer(tokenMulti));
    }
    if (multiEntry2) {
      await req.delete('/api/v1/entries/' + multiEntry2 + '?client_id=WZ77777').set(bearer(tokenMulti));
    }
  }

  // ══════════════════════════════════════════════════════════════════════════════
  // Cleanup
  // ══════════════════════════════════════════════════════════════════════════════
  console.log('\n── Cleanup ──');
  await cleanup();
  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, e.stack);
  pool.end();
  process.exit(1);
});
