Добавлен сервис 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).
149 lines
7.0 KiB
JavaScript
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); });
|