Files
WhatIDo/redis.selftest.js
T
dev 0e38a280d7 feat(redis): кэш, rate limit, баны IP и pub/sub через Redis
Добавлен сервис redis:7-alpine (AOF, requirepass, maxmemory + allkeys-lru,
healthcheck, том redis-data, порт только на 127.0.0.1) и абстракция redis.js
по образцу storage.js.

Переведено на Redis:
- кэш ответов API и настроек (было Map в памяти), инвалидация по префиксу
  через SCAN + DEL;
- rate limit для api/entry/file — общие счётчики вместо MemoryStore;
- баны IP и счётчики неудачных входа — с TTL, вместо опроса БД каждую минуту;
- кэш сессий (30 с) с invalidateSessions() на каждой мутации users/sessions/
  user_branches, иначе деактивированный пользователь сохранил бы доступ;
- pub/sub для SSE-событий и мгновенного пробуждения фоновых воркеров вместо
  ожидания цикла опроса БД.

Отказоустойчивость: при недоступном Redis все операции уходят в in-memory
backend с той же семантикой, приложение стартует и работает без Redis и
возвращается в Redis автоматически. Первое подключение ограничено по времени
(REDIS_CONNECT_TIMEOUT_MS, 5 с) — node-redis не отклоняет connect() при
недоступном сервере, а повторяет попытки бесконечно.

Добавлены тесты: redis.selftest.js (в т.ч. поведение при недоступном
сервере) и api.smoketest.js (сквозная проверка API, включая инвалидацию
кэша и мгновенную смерть сессии после logout).
2026-09-26 15:26:00 +03:00

149 lines
7.0 KiB
JavaScript

const assert = require('assert');
const { createRedis } = require('./redis');
function loadEnv() {
const fs = require('fs');
const path = require('path');
const file = path.join(__dirname, '.env');
if (!fs.existsSync(file)) return;
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
}
}
async function main() {
loadEnv();
const url = process.env.REDIS_URL || `redis://:${process.env.REDIS_PASSWORD || ''}@127.0.0.1:6379`;
const prefix = 'test-' + Date.now();
const cache = createRedis({ url, prefix });
const connected = await cache.connect();
console.log('connect:', connected, 'ready:', cache.isReady());
assert.strictEqual(connected, true, 'must connect');
assert.strictEqual(cache.isReady(), true, 'must be ready');
await cache.set('setting:foo', 'bar', 60000);
assert.strictEqual(await cache.get('setting:foo'), 'bar', 'string roundtrip');
await cache.set('obj', { a: 1, b: [2, 3], c: null }, 60000);
assert.deepStrictEqual(await cache.get('obj'), { a: 1, b: [2, 3], c: null }, 'json roundtrip');
assert.strictEqual(await cache.get('missing'), undefined, 'miss returns undefined');
await cache.set('setting:ttl', 'x', 120);
await new Promise(r => setTimeout(r, 250));
assert.strictEqual(await cache.get('setting:ttl'), undefined, 'ttl expiry');
await cache.set('groups:1', 'g1', 60000);
await cache.set('groups:2', 'g2', 60000);
await cache.set('students:1', 's1', 60000);
await cache.dropPrefix('groups:');
assert.strictEqual(await cache.get('groups:1'), undefined, 'dropPrefix removes groups:1');
assert.strictEqual(await cache.get('groups:2'), undefined, 'dropPrefix removes groups:2');
assert.strictEqual(await cache.get('students:1'), 's1', 'dropPrefix keeps students:1');
await cache.set('fail:login:1.2.3.4', 3, 60000);
await cache.set('fail:login:5.6.7.8', 9, 60000);
await cache.dropMatch('fail:*:1.2.3.4');
assert.strictEqual(await cache.get('fail:login:1.2.3.4'), undefined, 'dropMatch suffix removes target');
assert.strictEqual(await cache.get('fail:login:5.6.7.8'), 9, 'dropMatch keeps other ip');
const c1 = await cache.incr('counter:a', 60000);
const c2 = await cache.incr('counter:a', 60000);
assert.strictEqual(c1, 1, 'incr first = 1');
assert.strictEqual(c2, 2, 'incr second = 2');
await cache.incr('counter:short', 120);
await new Promise(r => setTimeout(r, 250));
assert.strictEqual(await cache.incr('counter:short', 60000), 1, 'incr ttl set on first call');
const hits = [];
const received = new Promise(resolve => {
cache.on('test:chan', msg => { hits.push(msg); resolve(msg); });
});
await new Promise(r => setTimeout(r, 200));
await cache.publish('test:chan', { hello: 'world' });
const got = await Promise.race([received, new Promise(r => setTimeout(() => r('TIMEOUT'), 3000))]);
assert.notStrictEqual(got, 'TIMEOUT', 'pubsub must deliver');
assert.deepStrictEqual(JSON.parse(got), { hello: 'world' }, 'pubsub payload');
assert.strictEqual(hits.length, 1, 'pubsub delivered exactly once (no double delivery)');
let wraps = 0;
const v1 = await cache.wrap('wrap:key', 60000, async () => { wraps += 1; return 'computed'; });
const v2 = await cache.wrap('wrap:key', 60000, async () => { wraps += 1; return 'other'; });
assert.strictEqual(v1, 'computed', 'wrap returns computed');
assert.strictEqual(v2, 'computed', 'wrap returns cached');
assert.strictEqual(wraps, 1, 'wrap computed once');
const store = cache.rateLimitStore('test', 60000);
await store.init({ windowMs: 60000 });
const r1 = await store.increment('ip1');
const r2 = await store.increment('ip1');
const r3 = await store.increment('ip2');
assert.strictEqual(r1.totalHits, 1, 'rl first');
assert.strictEqual(r2.totalHits, 2, 'rl second');
assert.strictEqual(r3.totalHits, 1, 'rl separate key');
assert.ok(r2.resetTime > Date.now(), 'rl resetTime in future');
assert.ok(r2.resetTime instanceof Date, 'rl resetTime is a Date');
assert.strictEqual(r1.resetTime.getTime(), r2.resetTime.getTime(), 'rl same window');
const ttl = await cache.info();
await store.decrement('ip1');
assert.strictEqual((await store.increment('ip1')).totalHits, 2, 'decrement then increment');
await store.resetKey('ip1');
assert.strictEqual((await store.increment('ip1')).totalHits, 1, 'resetKey clears');
await store.resetAll();
assert.strictEqual((await store.increment('ip1')).totalHits, 1, 'resetAll clears');
await cache.del('obj');
assert.strictEqual(await cache.get('obj'), undefined, 'del');
const info = await cache.info();
console.log('info:', JSON.stringify(info));
assert.strictEqual(info.driver, 'redis', 'info driver is redis');
assert.ok(info.used_memory_bytes > 0, 'info has memory');
await cache.clear();
assert.strictEqual(await cache.get('students:1'), undefined, 'clear removes all');
const other = createRedis({ url, prefix: prefix + '-other' });
await other.connect();
await other.set('iso', 'yes', 60000);
assert.strictEqual(await cache.get('iso'), undefined, 'namespaces are isolated');
await other.close();
await cache.close();
assert.strictEqual(cache.isReady(), false, 'closed');
const offline = createRedis({ url: 'redis://127.0.0.1:1/', prefix: 'off-' + Date.now() });
const okConn = await offline.connect();
assert.strictEqual(okConn, false, 'bad url must not throw');
await offline.set('k', 'v', 1000);
assert.strictEqual(await offline.get('k'), 'v', 'fallback set/get works');
await offline.set('groups:a', 1, 1000);
await offline.set('students:a', 1, 1000);
await offline.dropPrefix('groups:');
assert.strictEqual(await offline.get('groups:a'), undefined, 'fallback dropPrefix');
assert.strictEqual(await offline.get('students:a'), 1, 'fallback dropPrefix isolation');
assert.strictEqual(await offline.incr('c', 1000), 1, 'fallback incr');
assert.strictEqual(await offline.incr('c', 1000), 2, 'fallback incr 2');
const offStore = offline.rateLimitStore('t', 60000);
assert.strictEqual((await offStore.increment('k1')).totalHits, 1, 'fallback rl');
assert.strictEqual((await offStore.increment('k1')).totalHits, 2, 'fallback rl 2');
let localHit = null;
const localRecv = new Promise(r => { offline.on('c', m => { localHit = m; r(m); }); });
await offline.publish('c', { x: 1 });
assert.deepStrictEqual(JSON.parse(await Promise.race([localRecv, new Promise(r => setTimeout(() => r('T'), 2000))])), { x: 1 }, 'fallback local pubsub');
assert.deepStrictEqual(JSON.parse(localHit), { x: 1 }, 'fallback local payload');
await offline.close();
const disabled = createRedis({});
assert.strictEqual(await disabled.connect(), false, 'disabled connect false');
await disabled.set('a', 1, 1000);
assert.strictEqual(await disabled.get('a'), 1, 'disabled memory works');
await disabled.close();
console.log('\nALL REDIS TESTS PASSED');
process.exit(0);
}
main().catch(e => { console.error('FAILED:', e.message); console.error(e.stack); process.exit(1); });