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 tzBad = await api('/api/settings', { token, method: 'PUT', body: { settings: { timezone: 'Nope/Nope' } } }); ok('timezone: невалидная зона отклонена', tzBad.status === 400, tzBad.status); const fmtBad = await api('/api/settings', { token, method: 'PUT', body: { settings: { time_format: '36h' } } }); ok('time_format: невалидный формат отклонён', fmtBad.status === 400, fmtBad.status); const pubTz = await api('/api/public-settings'); ok('public-settings отдаёт timezone/time_format', !!pubTz.data.timezone && ['24h', '12h'].includes(pubTz.data.time_format), { timezone: pubTz.data.timezone, time_format: pubTz.data.time_format, }); const noFilter = await api('/api/files?limit=5', { token }); ok('files без фильтров: 200 (без висящих bind-параметров)', noFilter.status === 200, noFilter.status); const withFilter = await api('/api/files?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token }); ok('files с фильтром дат: 200', withFilter.status === 200, withFilter.status); const entriesNoFilter = await api('/api/entries?limit=5', { token }); ok('entries без фильтров: 200', entriesNoFilter.status === 200, entriesNoFilter.status); const entriesWithFilter = await api('/api/entries?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token }); ok('entries с фильтром дат: 200', entriesWithFilter.status === 200, entriesWithFilter.status); const photosFilter = await api('/api/photos?limit=5&date_from=2026-01-01&date_to=2026-12-31', { token }); ok('photos с фильтром дат: 200', photosFilter.status === 200, photosFilter.status); 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); // Фото-ИИ: photo_ai_enabled обязан совпадать с ai_configured — оба выводятся из // PHOTO_AI_URL, и флаг не попадает в кэш public-settings, иначе после перезапуска // с пустым PHOTO_AI_URL кнопка «🤖 ИИ» висела бы до истечения кэша (I3). // enhance-ai проверяем на несуществующей записи: 503 без фото-ИИ и 404 с фото-ИИ, // чтобы дымовой тест не создавал реальных заданий фото-воркеру. const photoStatus = await api('/api/photo-jobs/status', { token }); ok('photo-jobs/status -> 200', photoStatus.status === 200, photoStatus.status); ok('photo_ai_enabled согласован с ai_configured', pub.data.photo_ai_enabled === String(!!photoStatus.data.ai_configured), { photo_ai_enabled: pub.data.photo_ai_enabled, ai_configured: photoStatus.data.ai_configured, }); const workerCfg = photoStatus.data.worker && photoStatus.data.worker.config; ok('worker.config содержит лимит мягких повторов', Boolean(workerCfg && workerCfg.soft_max_retries > 0), workerCfg); // service обязан быть health фото-сервиса, а не текстового ИИ: configured совпадает // с ai_configured, reachable — булево, а при выключенном photo-ai сервис недоступен. const photoSvc = photoStatus.data.service; ok('photo-jobs/status -> service от фото-сервиса', Boolean(photoSvc) && photoSvc.configured === photoStatus.data.ai_configured && typeof photoSvc.reachable === 'boolean', { service: photoSvc, ai_configured: photoStatus.data.ai_configured, }); ok('service: без photo-ai reachable=false', photoStatus.data.ai_configured === false ? photoSvc.reachable === false : typeof photoSvc.latency_ms === 'number', { ai_configured: photoStatus.data.ai_configured, reachable: photoSvc.reachable, }); ok('worker.config.ai_url соответствует наличию фото-ИИ', Boolean(workerCfg) && workerCfg.ai_url === photoStatus.data.ai_url, { worker_ai_url: workerCfg && workerCfg.ai_url, ai_url: photoStatus.data.ai_url, }); const enhanceAi = await api('/api/entries/99999999/photo/enhance-ai', { token, method: 'POST' }); ok('enhance-ai: 503 без photo-ai либо 404 с photo-ai (запись не существует)', (photoStatus.data.ai_configured === false && enhanceAi.status === 503) || (photoStatus.data.ai_configured === true && enhanceAi.status === 404), { ai_configured: photoStatus.data.ai_configured, status: enhanceAi.status, body: enhanceAi.data, }); // Отчёты о занятии: контракт проверки по шаблону и истории версий. // Модель здесь не дёргаем (долго и нужен сервис) — проверяем постановку в очередь // и то, что при ai_check:false текст остаётся нетронутым. const lrGroups = await api('/api/groups', { token }); const gid = lrGroups.data[0] && lrGroups.data[0].id; if (gid) { const date = '2019-05-17'; await api(`/api/lesson-reports?group_id=${gid}&date_from=${date}&date_to=${date}`, { token }) .then(r => (r.data.items || []).forEach(i => api(`/api/lesson-reports/${i.id}`, { token, method: 'DELETE' }))); const plainText = 'Текст отчёта без проверки ИИ для смоук-теста.'; const plain = await api('/api/lesson-reports', { token, method: 'POST', body: { group_id: gid, lesson_date: date, lesson_time: '10:00', text: plainText, ai_check: false }, }); ok('lesson-report: создание без ai_check -> ai_status=none, text_original=null', plain.status === 201 && plain.data.ai_status === 'none' && plain.data.text_original === null, { status: plain.status, ai_status: plain.data.ai_status }); const versions = await api(`/api/lesson-reports/${plain.data.id}/versions`, { token }); ok('lesson-report: история версий содержит исходный текст', versions.status === 200 && Array.isArray(versions.data.items) && versions.data.items.length >= 1 && versions.data.items.some(v => v.text === plainText && v.source === 'manual'), { status: versions.status, items: (versions.data.items || []).length }); const badRestore = await api(`/api/lesson-reports/${plain.data.id}/versions/99999999/restore`, { token, method: 'POST' }); ok('lesson-report: восстановление несуществующей версии -> 404', badRestore.status === 404, badRestore.status); const noOrig = await api(`/api/lesson-reports/${plain.data.id}/ai/revert`, { token, method: 'POST' }); ok('lesson-report: откат без оригинала -> 400', noOrig.status === 400, noOrig.status); const del = await api(`/api/lesson-reports/${plain.data.id}`, { token, method: 'DELETE' }); ok('lesson-report: удаление', del.status === 200, del.status); } else { ok('lesson-report: есть группа для проверки', false, 'no groups'); } const lessonNotifyMeta = await api('/api/notifications/meta', { token }); // /api/notifications/meta отдаёт ключи настроек (notify_<тип>), а не сами типы ok('notifications: настройка lesson.ai.formatted заведена', (lessonNotifyMeta.data.types || []).some(t => t.key === 'notify_lesson_ai_formatted'), (lessonNotifyMeta.data.types || []).map(t => t.key)); 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); });