Внешние системы не могли разбудить воркер, переочередить упавшие
задания или отправить запись на повторную ИИ-проверку: все эти роуты
существовали только во внутреннем 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.
229 lines
16 KiB
JavaScript
229 lines
16 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, 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); }); |