Добавлена система уведомлений о системных и фоновых событиях (новые записи журнала, обработка фото, авто-проверка текста, блокировки IP, бэкапы). - backend (server.js, worker.js): - каталог NOTIFY_TYPES с метаданными и уровнями - таблицы notifications и notification_reads в db/init.sql и db/migration.sql - SSE-стрим GET /api/notifications/stream через Redis pub/sub с in-memory fallback - REST API: список, счётчик непрочитанных, отметка о прочтении, удаление, очистка - настройки уведомлений в settings (notify_enabled, notify_retention_days, notify_<тип>) - автоматическая очистка старых уведомлений по расписанию - frontend: - колокольчик со счётчиком непрочитанных в шапке (admin.js) - страница списка уведомлений public/notifications.html и public/js/notifications.js - секция настроек уведомлений в public/settings.html и public/js/settings.js - стили для уведомлений в public/admin.css - тесты и документация: - добавлены проверки в api.smoketest.js - обновлены README.md и AGENTS.md
190 lines
12 KiB
JavaScript
190 lines
12 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, headers: extra } = {}) {
|
||
const headers = {};
|
||
if (token) headers['X-Auth-Token'] = token;
|
||
if (body) headers['Content-Type'] = 'application/json';
|
||
Object.assign(headers, extra || {});
|
||
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 });
|
||
|
||
// Контракт авторизации. Держим в синхроне с AGENTS.md/README: доступ даёт только
|
||
// X-Auth-Token с токеном сессии. Никакой статический токен (в т.ч. ранее
|
||
// документированный X-Admin-Token = ADMIN_PASSWORD) доступа не даёт, и
|
||
// ADMIN_PASSWORD не является паролем для входа, кроме случая пустой БД,
|
||
// где он задаётся через login.
|
||
const PROTECTED = '/api/auth/me';
|
||
const ADMIN_ONLY = '/api/users';
|
||
const noAuth = await api(PROTECTED);
|
||
ok('auth: защищённый маршрут без токена -> 401', noAuth.status === 401, noAuth.status);
|
||
const garbage = await api(PROTECTED, { token: 'deadbeef' });
|
||
ok('auth: мусорный X-Auth-Token -> 401', garbage.status === 401, garbage.status);
|
||
const legacy = await api(PROTECTED, { headers: { 'X-Admin-Token': pass } });
|
||
ok('auth: X-Admin-Token не авторизует -> 401', legacy.status === 401, legacy.status);
|
||
const bearer = await api(PROTECTED, { headers: { Authorization: 'Bearer ' + token } });
|
||
ok('auth: Authorization Bearer не поддерживается -> 401', bearer.status === 401, bearer.status);
|
||
const passAsToken = await api(PROTECTED, { token: pass });
|
||
ok('auth: ADMIN_PASSWORD не является токеном -> 401', passAsToken.status === 401, passAsToken.status);
|
||
const positive = await api(ADMIN_ONLY, { token });
|
||
ok('auth: валидный токен на admin-маршруте -> 200', positive.status === 200, positive.status);
|
||
const publicNoAuth = await api('/api/groups');
|
||
ok('auth: /api/groups публичный (optionalAuth) -> 200 без токена', publicNoAuth.status === 200, publicNoAuth.status);
|
||
|
||
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 stack = info.data && info.data.stack;
|
||
ok('system-info reports stack', Boolean(stack && stack.app && stack.runtime && stack.database && stack.cache && stack.storage), stack);
|
||
ok('stack reports node and postgres version', Boolean(stack && stack.app.node && stack.database.version), stack && { node: stack.app.node, pg: stack.database.version });
|
||
ok('stack never leaks credentials', !/:\/\/[^"@/]*@/.test(JSON.stringify(stack)), stack && stack.database.host, stack && stack.cache.host);
|
||
|
||
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 notifyNoAuth = await api('/api/notifications');
|
||
ok('notifications: список без токена -> 401', notifyNoAuth.status === 401, notifyNoAuth.status);
|
||
const notifyList = await api('/api/notifications?limit=5', { token });
|
||
ok('notifications: список -> 200', notifyList.status === 200 && Array.isArray(notifyList.data.items) && typeof notifyList.data.unread === 'number', notifyList.status);
|
||
const notifyMeta = await api('/api/notifications/meta', { token });
|
||
ok('notifications: meta перечисляет типы событий', notifyMeta.status === 200 && Array.isArray(notifyMeta.data.types) && notifyMeta.data.types.length > 0, notifyMeta.status);
|
||
ok('notifications: meta содержит тип ip.ban', Boolean((notifyMeta.data.types || []).find(t => t.type === 'ip.ban')), (notifyMeta.data.types || []).map(t => t.type));
|
||
const notifyCreate = await api('/api/notifications/test', { token, method: 'POST' });
|
||
ok('notifications: тестовое уведомление создано', notifyCreate.status === 200 && notifyCreate.data.id > 0 && notifyCreate.data.delivered === true, notifyCreate.data);
|
||
const notifyUnread = await api('/api/notifications?unread=1', { token });
|
||
ok('notifications: непрочитанные растут', notifyUnread.data.unread >= 1, notifyUnread.data.unread);
|
||
const notifyRead = await api('/api/notifications/' + notifyCreate.data.id + '/read', { token, method: 'POST' });
|
||
ok('notifications: отметить уведомление прочитанным', notifyRead.status === 200, notifyRead.status);
|
||
const notifyReadAll = await api('/api/notifications/read-all', { token, method: 'POST' });
|
||
ok('notifications: отметить всё прочитанным', notifyReadAll.status === 200 && notifyReadAll.data.unread === 0, notifyReadAll.data);
|
||
const notifyStream = await fetch(BASE + '/api/notifications/stream?token=' + encodeURIComponent(token));
|
||
ok('notifications: SSE открывается', notifyStream.status === 200, notifyStream.status);
|
||
if (notifyStream.status === 200) {
|
||
const reader = notifyStream.body.getReader();
|
||
const first = await reader.read();
|
||
const text = new TextDecoder().decode(first.value || new Uint8Array());
|
||
ok('notifications: SSE отдаёт ready-кадр', text.includes('event: ready') || text.includes(':ok'), JSON.stringify(text.slice(0, 60)));
|
||
reader.cancel().catch(() => {});
|
||
}
|
||
const notifyOff = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_system_test: 'false' } } });
|
||
const notifySuppressed = await api('/api/notifications/test', { token, method: 'POST' });
|
||
ok('notifications: выключенный тип не создаётся', notifyOff.status === 200 && notifySuppressed.status === 200 && notifySuppressed.data.id === null, notifySuppressed.data);
|
||
const notifyOn = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_system_test: 'true' } } });
|
||
ok('notifications: тип включается обратно', notifyOn.status === 200, notifyOn.status);
|
||
const notifyBadSetting = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_entry_new: 'maybe' } } });
|
||
ok('notifications: неверное значение настройки -> 400', notifyBadSetting.status === 400, notifyBadSetting.status);
|
||
const notifyClearNoAuth = await api('/api/notifications', { method: 'DELETE' });
|
||
ok('notifications: очистка без токена -> 401', notifyClearNoAuth.status === 401, notifyClearNoAuth.status);
|
||
const notifyDel = await api('/api/notifications/' + notifyCreate.data.id, { token, method: 'DELETE' });
|
||
ok('notifications: удаление уведомления', notifyDel.status === 200, notifyDel.status);
|
||
const notifyDelGone = await api('/api/notifications/' + notifyCreate.data.id + '/read', { token, method: 'POST' });
|
||
ok('notifications: удалённое уведомление -> 404', notifyDelGone.status === 404, notifyDelGone.status);
|
||
|
||
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); });
|