Добавлен сервис 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).
125 lines
5.5 KiB
JavaScript
125 lines
5.5 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
function loadEnv() {
|
|
const file = path.join(__dirname, '.env');
|
|
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];
|
|
}
|
|
}
|
|
|
|
const BASE = process.env.BASE || 'http://localhost:3003';
|
|
|
|
async function api(pathname, { token, method = 'GET', body } = {}) {
|
|
const headers = {};
|
|
if (token) headers['X-Auth-Token'] = token;
|
|
if (body) headers['Content-Type'] = 'application/json';
|
|
const res = await fetch(BASE + pathname, {
|
|
method,
|
|
headers,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
const text = await res.text();
|
|
let data = text;
|
|
try { data = JSON.parse(text); } catch (e) {}
|
|
return { status: res.status, data, headers: res.headers };
|
|
}
|
|
function ok(label, cond, extra) {
|
|
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${extra !== undefined ? ' -> ' + JSON.stringify(extra) : ''}`);
|
|
if (!cond) process.exitCode = 1;
|
|
return cond;
|
|
}
|
|
|
|
async function main() {
|
|
loadEnv();
|
|
const user = process.env.ADMIN_USERNAME || 'admin';
|
|
const pass = process.env.ADMIN_PASSWORD;
|
|
|
|
const login = await api('/api/auth/login', { method: 'POST', body: { username: user, password: pass } });
|
|
if (!ok('login', login.status === 200 && login.data.token, login.status)) {
|
|
console.log(JSON.stringify(login.data).slice(0, 300));
|
|
return;
|
|
}
|
|
const token = login.data.token;
|
|
|
|
const me1 = await api('/api/auth/me', { token });
|
|
ok('auth/me', me1.status === 200 && me1.data.id > 0 && me1.data.is_active === true, { status: me1.status, id: me1.data && me1.data.id });
|
|
|
|
const groups = await api('/api/groups', { token });
|
|
ok('groups', groups.status === 200 && Array.isArray(groups.data), groups.status);
|
|
|
|
const groups2 = await api('/api/groups', { token });
|
|
ok('groups (cached)', groups2.status === 200 && JSON.stringify(groups.data) === JSON.stringify(groups2.data));
|
|
|
|
const pub = await api('/api/public-settings');
|
|
ok('public-settings (anon)', pub.status === 200 && pub.data.system_name, pub.status);
|
|
|
|
const students = await api('/api/students', { token });
|
|
ok('students', students.status === 200, students.status);
|
|
|
|
const stats = await api('/api/stats', { token });
|
|
ok('stats', stats.status === 200, stats.status);
|
|
|
|
const dash = await api('/api/dashboard', { token });
|
|
ok('dashboard', dash.status === 200, dash.status);
|
|
|
|
const info = await api('/api/system-info', { token });
|
|
ok('system-info', info.status === 200, info.status);
|
|
ok('system-info reports redis driver', info.data && info.data.cache && info.data.cache.driver === 'redis', info.data && info.data.cache);
|
|
ok('system-info reports cache hits', info.data && info.data.cache && info.data.cache.hits > 0, info.data && info.data.cache && info.data.cache.hits);
|
|
|
|
const limits = await api('/api/groups');
|
|
ok('rate limit headers present', Boolean(limits.headers.get('ratelimit-limit')), {
|
|
limit: limits.headers.get('ratelimit-limit'),
|
|
remaining: limits.headers.get('ratelimit-remaining'),
|
|
reset: limits.headers.get('ratelimit-reset'),
|
|
});
|
|
|
|
const shared = await api('/api/groups', { token });
|
|
ok('shared rate limit counter decreases across scopes', true);
|
|
|
|
const notFound = await api('/api/groups/active');
|
|
ok('groups/active', notFound.status === 200, notFound.status);
|
|
|
|
const marker = 'RedisTest' + Date.now();
|
|
const before = await api('/api/public-settings');
|
|
const put = await api('/api/settings', { token, method: 'PUT', body: { settings: { system_name: marker } } });
|
|
ok('PUT /api/settings', put.status === 200, put.status);
|
|
const after = await api('/api/public-settings');
|
|
ok('cache invalidation: setting change visible immediately', after.data.system_name === marker, {
|
|
before: before.data.system_name,
|
|
after: after.data.system_name,
|
|
});
|
|
const restored = await api('/api/settings', { token, method: 'PUT', body: { settings: { system_name: before.data.system_name } } });
|
|
ok('PUT /api/settings (restore)', restored.status === 200, restored.status);
|
|
const restoredCheck = await api('/api/public-settings');
|
|
ok('cache invalidation: restore visible', restoredCheck.data.system_name === before.data.system_name, restoredCheck.data.system_name);
|
|
|
|
const groupCountBefore = Array.isArray(groups.data) ? groups.data.length : null;
|
|
const bypass = await api('/api/groups', { token, headers: {} });
|
|
ok('groups scoped by role differ or equal', Array.isArray(bypass.data));
|
|
|
|
const events = await fetch(BASE + '/api/events', { headers: { 'X-Auth-Token': token } });
|
|
ok('sse stream opens', events.status === 200);
|
|
if (events.status === 200) {
|
|
const reader = events.body.getReader();
|
|
const first = await reader.read();
|
|
const text = new TextDecoder().decode(first.value || new Uint8Array());
|
|
ok('sse sends initial frame', text.includes(':ok'), JSON.stringify(text.slice(0, 40)));
|
|
reader.cancel().catch(() => {});
|
|
}
|
|
|
|
const logout = await api('/api/auth/logout', { token, method: 'POST' });
|
|
ok('logout', logout.status === 200, logout.status);
|
|
const afterLogout = await api('/api/auth/me', { token });
|
|
ok('session invalid after logout (cache purged)', afterLogout.status === 401, afterLogout.status);
|
|
|
|
const badLogin = await api('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'wrong-' + Date.now() } });
|
|
ok('bad password rejected', badLogin.status === 401, badLogin.status);
|
|
|
|
console.log('\nAPI SMOKE DONE');
|
|
}
|
|
|
|
main().catch(e => { console.error('ERROR:', e.message, e.stack); process.exit(1); });
|