Files
WhatIDo/api.smoketest.js
T
dev 403574fe79 chore(photo-ai): раздел 0 — жёсткие инварианты I1–I7 зафиксированы
План фото-ИИ с восстановлением лиц разбит на этапы; этот коммит закрывает
раздел 0 — семь инвариантов, которые нельзя ломать дальше. Два из них были
нарушены в текущем коде и исправлены здесь.

I5 (мягкие ошибки не сжигают попытки). Раньше любой сбой photo-ai —
503, обрыв сети, таймаут — попадал в общий catch, инкрементил attempts и
через три попытки переводил задание в error. Теперь ошибки разделены:
5xx/429/425/408 и сетевая недоступность возвращают задание в pending без
инкремента attempts, с экспоненциальной паузой 10 с → 300 с; лимит мягких
повторов (по умолчанию 60) даёт одну честную ошибку с понятным текстом.
Таймаут AbortSignal.timeout — жёсткая ошибка с попытками, как и раньше.
Счётчик мягких повторов живёт в памяти процесса и в счётчиках воркера,
метаданные повтора — в audit_log.target (soft_attempt/soft_limit), без
новых колонок. Новый аудит-код photo.job.soft_retry и подпись в audit.js.
Переменные PHOTO_AI_SOFT_MAX_RETRIES, PHOTO_AI_SOFT_BACKOFF_MS,
PHOTO_AI_SOFT_BACKOFF_MAX_MS описаны в .env.example и отдаются в
worker.config в GET /api/photo-jobs/status.

I7 (никаких прямых fs.* по uploads/). runAiEnhance писал результат
fs.writeFileSync в uploads/ и только потом persist в S3; enhanceWithSharp
делал то же через sharp toFile. Оба теперь считают буфер и пишут его
через storage.put — драйвер выбирает сам, локальной копии не остаётся.
Из worker.js убраны require('fs'), require('path') и параметр uploadsDir.

I3 (photo-ai не обязателен). photo_ai_enabled вычислялся внутри
cacheWrap('public-settings'), поэтому после перезапуска с пустым
PHOTO_AI_URL кнопка «🤖 ИИ» оставалась видимой до истечения кэша (60 с),
хотя enhance-ai уже отдавал 503. Флаг вынесен из кэша: он выводится из
PHOTO_AI_URL в памяти процесса и всегда актуален.

Проверено на стенде (журнал — в TODO_PHOTO_FACE_AI.md, раздел 0):
- I1: эталон /enhance снят на 5 фото (3 реальных, 2 синтетических),
  два независимых прогона и прогон после правок совпали байт-в-байт
  (sha256), /health отдаёт ok. Скрипты и эталон — в backups/ (вне git)
- I2: задание с params IS NULL и action='ai' дошло до done при
  attempts=0, результат отдан из S3 (200)
- I3: с пустым PHOTO_AI_URL photo_ai_enabled=false сразу после старта,
  enhance-ai → 503, остальные маршруты API живы
- I4: nvidia-ctk и nvidia-container-runtime на хосте отсутствуют, runtime
  только runc — фото-ИИ поднялся на CPU, /health не падает. Проверка
  PHOTO_AI_DEVICE переносится на приёмку Stage 1 (переменной ещё нет)
- I6: db/ не тронут, состав колонок photo_jobs прежний
- I7: node --check для всех изменённых JS, комментариев в диффе нет,
  весь SQL параметризован

api.smoketest.js: контракт фото-воркера — согласованность
photo_ai_enabled и ai_configured, ключи мягких повторов в worker.config,
503/404 для enhance-ai на несуществующей записи (тест не создаёт реальных
заданий). Вместе с планом и чек-листом этапов.
2026-09-28 23:19:16 +03:00

214 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
// Фото-ИИ: 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);
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,
});
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); });