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); } } 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); 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); });