Files
WhatIDo/api-keys.selftest.js
T
dev 5667198c9b feat(api): управление ИИ-воркерами через внешний API
Внешние системы не могли разбудить воркер, переочередить упавшие
задания или отправить запись на повторную ИИ-проверку: все эти роуты
существовали только во внутреннем API под requireAdmin.

Добавлено на apiV1 (все под apiWrite('write')):
- POST /ai/wake, /photo-jobs/wake — пинок воркеров
- POST /ai/requeue-failed, /photo-jobs/requeue-failed — error -> pending
- POST /entries/:id/ai/recheck — повторная проверка конкретной записи

Филиальная изоляция (главное в этом изменении):
- внутренние requeue-failed делают UPDATE по всей таблице; перенос их
  как есть позволил бы ключу с ограничением по филиалу переочередить
  чужие задания, что ломает правило «ключ не шире выдавшего»
- добавлен хелпер apiBranchClause(user, expr, params): пустая строка
  для admin, AND FALSE при пустом списке филиалов, иначе
  AND <expr> = ANY($N::int[]); применён к обоим массовым UPDATE
- entries фильтруется через groups.branch_id, photo_jobs — через
  photo_jobs -> entries -> groups

Аудит через apiAudit() с префиксом api., метки добавлены в
public/js/audit.js; после мутаций invalidateEntries/invalidateStats
и broadcastEntryChanged.

Воркер отчётов о занятии wake-эндпоинта не получает: он будится сам
из POST/PUT /lesson-reports при ai_check === true.

Документация: таблица эндпоинтов и раздел про воркеров в README.md,
правило apiBranchClause в AGENTS.md 3f.

Проверено: изолированный тест на двух филиалах — requeue-failed
ключом одного филиала вернул count 1 из двух ошибочных заданий,
запись и фото-джоб чужого филиала остались в error, recheck чужой
записи 403; api-keys.selftest.js 61 PASS, api.smoketest.js 76 PASS,
регрессий нет.

Замечание: server.js запечён в образ, compose монтирует только
uploads/, поэтому restart правку не подхватит — нужен
./scripts/deploy.sh или docker compose up -d --build app.
2026-10-05 00:06:38 +03:00

229 lines
16 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, apiKey, method = 'GET', body, headers: extra } = {}) {
const headers = {};
if (token) headers['X-Auth-Token'] = token;
if (apiKey) headers['X-Api-Key'] = apiKey;
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 && extra !== null ? ' -> ' + JSON.stringify(extra) : ''}`);
if (!cond) process.exitCode = 1;
return cond;
}
const V1 = (key, p, opts) => api('/api/v1' + p, { ...opts, apiKey: key });
const unbanSelf = async (token) => {
const bans = await api('/api/bans', { token });
if (!Array.isArray(bans.data)) return;
for (const b of bans.data) {
if (b.reason === 'apikey-bruteforce') await api('/api/bans/' + encodeURIComponent(b.ip), { token, method: 'DELETE' });
}
};
async function main() {
loadEnv();
const login = await api('/api/auth/login', { method: 'POST', body: { username: process.env.ADMIN_USERNAME || 'admin', password: process.env.ADMIN_PASSWORD } });
if (login.status === 403) {
console.log('IP заблокирован защитой от подбора ключей (тест делает несколько заведомо неверных ключей за прогон).');
console.log('Снимите бан в админке «Блокировки» и повторите запуск.');
return;
}
if (!ok('login', login.status === 200 && login.data.token, login.status)) return;
const token = login.data.token;
await unbanSelf(token);
const created = await api('/api/api-keys', { token, method: 'POST', body: { name: 'SelfTest read ' + Date.now(), scopes: ['read'] } });
ok('api-keys: создание ключа -> 201 с секретом',
created.status === 201 && typeof created.data.key === 'string' && created.data.key.startsWith('wsk_'),
{ status: created.status, prefix: created.data && created.data.prefix });
const rawKey = created.data.key;
const keyId = created.data.id;
const meta = await api('/api/api-keys/meta', { token });
ok('api-keys: meta отдаёт scopes', meta.status === 200 && meta.data.scopes && meta.data.scopes.read && meta.data.scopes.write, meta.data);
ok('api-keys: секрет не возвращается в листинге',
await api('/api/api-keys', { token }).then(r => Array.isArray(r.data) && r.data.every(k => k.key === undefined)), null);
const me = await V1(rawKey, '/me');
ok('api v1: /me по X-Api-Key -> 200', me.status === 200 && me.data.user && me.data.user.role === 'admin', me.data && me.data.user);
ok('api v1: Authorization Bearer тоже работает', (await api('/api/v1/me', { headers: { Authorization: 'Bearer ' + rawKey } })).status === 200, null);
ok('api v1: секрет ключа не утекает в /me', me.data.key && me.data.key.key === undefined, me.data.key);
ok('api v1: без ключа -> 401', (await api('/api/v1/me')).status === 401, null);
ok('api v1: неверный ключ -> 401', (await api('/api/v1/me', { headers: { 'X-Api-Key': 'wsk_' + '0'.repeat(64) } })).status === 401, null);
ok('api v1: токен сессии не работает как API-ключ', (await api('/api/v1/me', { headers: { 'X-Api-Key': token } })).status === 401, null);
for (const p of ['/branches', '/groups', '/students', '/modules', '/entries', '/lesson-reports', '/stats']) {
const r = await V1(rawKey, p);
ok('api v1: чтение ' + p, r.status === 200, { status: r.status, error: r.data && r.data.error });
}
const gList = await V1(rawKey, '/groups?limit=2');
ok('api v1: список возвращает {items,total,limit,offset}',
gList.status === 200 && Array.isArray(gList.data.items) && typeof gList.data.total === 'number' && gList.data.limit === 2,
{ status: gList.status, keys: Object.keys(gList.data || {}) });
const g = (gList.data.items && gList.data.items[0]) || null;
const studentName = 'SelftestApi ' + Date.now();
if (g) {
const denied = await V1(rawKey, '/entries', { method: 'POST', body: { student_name: studentName, group_id: g.id, description: 'read-only' } });
ok('api v1: ключ без scope write -> 403', denied.status === 403, { status: denied.status, error: denied.data && denied.data.error });
} else {
ok('api v1: есть группа для проверки записи', false, 'no groups');
}
const writeKey = await api('/api/api-keys', { token, method: 'POST', body: { name: 'SelfTest write ' + Date.now(), scopes: ['read', 'write'] } });
ok('api-keys: создание ключа со scope write', writeKey.status === 201, writeKey.status);
if (g) {
const ce = await V1(writeKey.data.key, '/entries', { method: 'POST', body: { student_name: studentName, group_id: g.id, description: 'Создано через API' } });
ok('api v1: POST /entries со scope write -> 201', ce.status === 201 && ce.data.id > 0, { status: ce.status, error: ce.data && ce.data.error });
if (ce.status === 201) {
const upd = await V1(writeKey.data.key, '/entries/' + ce.data.id, { method: 'PUT', body: { description: 'Обновлено через API' } });
ok('api v1: PUT /entries/:id', upd.status === 200 && upd.data.description === 'Обновлено через API', { status: upd.status, error: upd.data && upd.data.error });
const one = await V1(writeKey.data.key, '/entries/' + ce.data.id);
ok('api v1: GET /entries/:id отдаёт изменённое', one.status === 200 && one.data.description === 'Обновлено через API', one.status);
ok('api v1: GET /entries/:id/files', (await V1(writeKey.data.key, '/entries/' + ce.data.id + '/files')).status === 200, null);
ok('api v1: DELETE /entries/:id (soft delete)', (await V1(writeKey.data.key, '/entries/' + ce.data.id, { method: 'DELETE' })).status === 200, null);
const after = await V1(writeKey.data.key, '/entries?search=' + encodeURIComponent(studentName));
ok('api v1: удалённая запись не выдаётся в списке',
after.status === 200 && !after.data.items.some(i => i.id === ce.data.id),
{ status: after.status, ids: (after.data.items || []).map(i => i.id) });
}
const lessonDate = new Date().toISOString().slice(0, 10);
const lr = await V1(writeKey.data.key, '/lesson-reports', { method: 'POST', body: { group_id: g.id, lesson_date: lessonDate, lesson_time: '10:00', text: 'Отчёт через API' } });
ok('api v1: POST /lesson-reports', lr.status === 201 && lr.data.id > 0, { status: lr.status, error: lr.data && lr.data.error });
if (lr.status === 201) {
const lrList = await V1(writeKey.data.key, '/lesson-reports?group_id=' + g.id + '&date_from=' + lessonDate);
ok('api v1: отчёт виден в списке по дате', lrList.status === 200 && lrList.data.items.some(r => r.id === lr.data.id), { status: lrList.status, total: lrList.data && lrList.data.total });
ok('api v1: DELETE /lesson-reports/:id', (await V1(writeKey.data.key, '/lesson-reports/' + lr.data.id, { method: 'DELETE' })).status === 200, null);
}
}
if (g) {
for (const p of ['/ai/wake', '/photo-jobs/wake', '/ai/requeue-failed', '/photo-jobs/requeue-failed']) {
const r = await V1(rawKey, p, { method: 'POST' });
ok('api v1: ключ без scope write -> 403 на ' + p, r.status === 403, { status: r.status, error: r.data && r.data.error });
}
const rc = await V1(rawKey, '/entries/' + g.id + '/ai/recheck', { method: 'POST' });
ok('api v1: ключ без scope write -> 403 на recheck', rc.status === 403, { status: rc.status, error: rc.data && rc.data.error });
for (const p of ['/ai/wake', '/photo-jobs/wake']) {
const r = await V1(writeKey.data.key, p, { method: 'POST' });
ok('api v1: POST ' + p + ' со scope write -> 200', r.status === 200 && r.data.ok === true, { status: r.status, error: r.data && r.data.error });
}
for (const p of ['/ai/requeue-failed', '/photo-jobs/requeue-failed']) {
const r = await V1(writeKey.data.key, p, { method: 'POST' });
ok('api v1: POST ' + p + ' -> 200 с count', r.status === 200 && r.data.ok === true && typeof r.data.count === 'number', { status: r.status, data: r.data });
}
const rc404 = await V1(writeKey.data.key, '/entries/99999999/ai/recheck', { method: 'POST' });
ok('api v1: recheck несуществующей записи -> 404', rc404.status === 404, { status: rc404.status, error: rc404.data && rc404.data.error });
const ce2 = await V1(writeKey.data.key, '/entries', { method: 'POST', body: { student_name: studentName, group_id: g.id, description: 'Для recheck' } });
if (ce2.status === 201) {
const rc2 = await V1(writeKey.data.key, '/entries/' + ce2.data.id + '/ai/recheck', { method: 'POST' });
ok('api v1: POST /entries/:id/ai/recheck -> 200', rc2.status === 200 && rc2.data.ok === true, { status: rc2.status, error: rc2.data && rc2.data.error });
const one2 = await V1(writeKey.data.key, '/entries/' + ce2.data.id);
ok('api v1: запись после recheck в очереди на ИИ-проверку',
one2.status === 200 && ['pending', 'processing'].includes(one2.data.ai_status),
{ status: one2.status, ai_status: one2.data && one2.data.ai_status });
await V1(writeKey.data.key, '/entries/' + ce2.data.id, { method: 'DELETE' });
} else {
ok('api v1: запись для проверки recheck создана', false, { status: ce2.status });
}
}
const rot = await api('/api/api-keys/' + writeKey.data.id + '/rotate', { token, method: 'POST' });
ok('api-keys: ротация выдаёт новый секрет', rot.status === 200 && typeof rot.data.key === 'string' && rot.data.key !== writeKey.data.key, rot.status);
ok('api v1: старый ключ мёртв после ротации', (await api('/api/v1/me', { apiKey: writeKey.data.key })).status === 401, null);
ok('api v1: новый ключ работает после ротации', (await api('/api/v1/me', { apiKey: rot.data.key })).status === 200, null);
await api('/api/api-keys/' + writeKey.data.id, { token, method: 'DELETE' });
const updKey = await api('/api/api-keys/' + keyId, { token, method: 'PUT', body: { name: 'Renamed ' + Date.now(), scopes: ['read'] } });
ok('api-keys: PUT обновляет ключ', updKey.status === 200 && updKey.data.name.startsWith('Renamed'), updKey.status);
ok('api-keys: некорректный лимит -> 400', (await api('/api/api-keys/' + keyId, { token, method: 'PUT', body: { rate_limit_per_min: 999999 } })).status === 400, null);
ok('api-keys: DELETE ключа', (await api('/api/api-keys/' + keyId, { token, method: 'DELETE' })).status === 200, null);
ok('api v1: удалённый ключ -> 401', (await api('/api/v1/me', { apiKey: rawKey })).status === 401, null);
ok('api-keys: список без сессии -> 401', (await api('/api/api-keys')).status === 401, null);
ok('api-keys: X-Admin-Token не авторизует -> 401', (await api('/api/api-keys', { headers: { 'X-Admin-Token': token } })).status === 401, null);
const limited = await api('/api/api-keys', { token, method: 'POST', body: { name: 'RL test', scopes: ['read'], rate_limit_per_min: 3 } });
const rlKey = limited.data.key;
const codes = [];
let rlHeaders = null;
for (let i = 0; i < 5; i++) {
const r = await api('/api/v1/stats', { apiKey: rlKey });
codes.push(r.status);
rlHeaders = r.headers;
}
ok('api-keys: индивидуальный лимит rpm соблюдается',
codes.slice(0, 3).every(c => c === 200) && codes.slice(3).some(c => c === 429),
{ codes, limit: rlHeaders && rlHeaders.get('ratelimit-limit') });
ok('api-keys: заголовки ratelimit присутствуют', Boolean(rlHeaders && rlHeaders.get('ratelimit-limit')), null);
await api('/api/api-keys/' + limited.data.id, { token, method: 'DELETE' });
const expired = await api('/api/api-keys', { token, method: 'POST', body: { name: 'Expired', scopes: ['read'], expires_at: '2000-01-01T00:00:00Z' } });
ok('api v1: просроченный ключ -> 401', (await api('/api/v1/me', { apiKey: expired.data.key })).status === 401, null);
await api('/api/api-keys/' + expired.data.id, { token, method: 'DELETE' });
const branches = await api('/api/branches', { token });
if (Array.isArray(branches.data) && branches.data.length >= 1) {
const only = await api('/api/api-keys', { token, method: 'POST', body: { name: 'Branch 1 ' + Date.now(), scopes: ['read'], branch_ids: [branches.data[0].id] } });
ok('api-keys: ключ с ограничением по филиалу создан', only.status === 201, only.status);
const scoped = await api('/api/v1/branches', { apiKey: only.data.key });
ok('api v1: ключ видит только свой филиал',
scoped.status === 200 && scoped.data.items.length === 1 && scoped.data.items[0].id === branches.data[0].id,
{ status: scoped.status, ids: (scoped.data.items || []).map(b => b.id) });
const sc = await api('/api/v1/me', { apiKey: only.data.key });
ok('api v1: филиал ограничивает права доступа',
sc.data.user.role === 'tutor' && sc.data.user.branch_ids.length === 1 && sc.data.user.branch_ids[0] === branches.data[0].id,
sc.data.user);
const bad = await api('/api/api-keys/' + only.data.id, { token, method: 'PUT', body: { branch_ids: [999999] } });
ok('api-keys: несуществующий филиал -> 400', bad.status === 400, bad.status);
const adminGroups = await api('/api/groups?limit=500', { token });
const foreign = (adminGroups.data || []).find(x => x.branch_id && x.branch_id !== branches.data[0].id);
if (foreign) {
const wOnly = await api('/api/api-keys/' + only.data.id, { token, method: 'PUT', body: { scopes: ['read', 'write'] } });
ok('api v1: ключу с филиалом выдан scope write', wOnly.status === 200, wOnly.status);
const own = await api('/api/groups?limit=1', { apiKey: only.data.key });
const rq = await V1(only.data.key, '/ai/requeue-failed', { method: 'POST' });
ok('api v1: requeue-failed ключом с филиалом -> 200, count число',
rq.status === 200 && typeof rq.data.count === 'number', { status: rq.status, data: rq.data });
ok('api v1: ключ с филиалом не видит чужие группы',
own.status === 200 && (own.data.items || []).every(x => x.branch_id === branches.data[0].id),
{ status: own.status, branchIds: (own.data.items || []).map(x => x.branch_id) });
const rp = await V1(only.data.key, '/photo-jobs/requeue-failed', { method: 'POST' });
ok('api v1: photo requeue-failed ключом с филиалом -> 200, count число',
rp.status === 200 && typeof rp.data.count === 'number', { status: rp.status, data: rp.data });
} else {
ok('api v1: есть группа чужого филиала для проверки', false, 'no foreign group');
}
await api('/api/api-keys/' + only.data.id, { token, method: 'DELETE' });
} else {
ok('api v1: есть филиал для проверки скоупа', false, 'no branches');
}
await unbanSelf(token);
console.log('\nAPI KEYS SELFTEST DONE');
}
main().catch(e => { console.error('ERROR:', e.message, e.stack); process.exit(1); });