Files
magistr/frontend/tests/security-policy.test.mjs
dipatrik10 97bf3cc50a feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента
полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md).

- 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой
- Удалены legacy-views и переходный слой LegacyTabView (45 файлов)
- Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки
- CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf)
- Фиксы: lazy-импорт именованных экспортов (краш после авторизации),
  растяжение #admin-root/#settings-root на всю ширину,
  возврат subjects/lessonTypes из справочников (краш просмотра расписаний),
  отказ от заведомо широкого поиска расписания без выбранной цели,
  центрирование карточки логина
- Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину
- Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
2026-09-28 23:15:04 +03:00

180 lines
9.1 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.
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readdir, readFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const frontendRoot = fileURLToPath(new URL('..', import.meta.url));
const sourceExtensions = new Set(['.html', '.js', '.jsx', '.mjs', '.css']);
async function sourceFiles(directory = frontendRoot) {
const result = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'tests') continue;
const path = join(directory, entry.name);
if (entry.isDirectory()) {
result.push(...await sourceFiles(path));
} else if (sourceExtensions.has(extname(entry.name))) {
result.push(path);
}
}
return result;
}
async function sourceCorpus() {
const files = await sourceFiles();
return Promise.all(files.map(async path => ({ path, text: await readFile(path, 'utf8') })));
}
test('frontend не исполняет удалённые или inline-скрипты', async () => {
const corpus = await sourceCorpus();
for (const { path, text } of corpus) {
assert.doesNotMatch(text, /(?:from\s+|import\s*\()\s*['"]https?:\/\//, path);
if (extname(path) === '.html') {
assert.doesNotMatch(text, /<script\b(?![^>]*\bsrc=)[^>]*>/i, path);
assert.doesNotMatch(text, /<style\b/i, path);
assert.doesNotMatch(text, /\son[a-z]+\s*=/i, path);
}
}
});
test('access JWT и профиль сессии не читаются и не записываются в Web Storage', async () => {
const corpus = await sourceCorpus();
const authStorage = /(?:localStorage|sessionStorage)\.(?:getItem|setItem)\(\s*['"](?:token|role|departmentId|userId)['"]/;
for (const { path, text } of corpus) {
assert.doesNotMatch(text, authStorage, path);
}
});
test('CSP запрещает произвольные inline-ресурсы и покрывает style-атрибуты хэшами', async () => {
const corpus = await sourceCorpus();
const config = await readFile(join(frontendRoot, 'security.conf'), 'utf8');
assert.match(config, /script-src 'self'/);
assert.match(config, /script-src-attr 'none'/);
assert.match(config, /style-src 'self'/);
assert.match(config, /style-src-attr 'unsafe-hashes'/);
assert.match(config, /connect-src 'self'/);
assert.doesNotMatch(config, /'unsafe-inline'|'unsafe-eval'/);
const expectedHashes = new Set();
for (const { path, text } of corpus) {
assert.doesNotMatch(text, /\.style\.(?:cssText|[A-Za-z-]+)\s*=|\.style\.setProperty|setAttribute\(\s*['"]style/i, path);
for (const match of text.matchAll(/style="([^"]*)"/g)) {
assert.doesNotMatch(match[1], /\$\{/, `Динамический style-атрибут в ${path}`);
expectedHashes.add(`sha256-${createHash('sha256').update(match[1]).digest('base64')}`);
}
}
const configuredHashes = new Set(
Array.from(config.matchAll(/'((?:sha256)-[^']+)'/g), match => match[1])
);
assert.deepEqual(configuredHashes, expectedHashes);
});
test('Docker-образ включает локальный telemetry bundle и security headers', async () => {
const dockerfile = await readFile(join(frontendRoot, 'Dockerfile'), 'utf8');
const telemetry = await readFile(join(frontendRoot, 'telemetry.js'), 'utf8');
assert.match(dockerfile, /npm ci/);
assert.match(dockerfile, /npm run build/);
assert.match(dockerfile, /scripts\/build-react\.mjs scripts\/build-vendor\.mjs/);
assert.match(dockerfile, /COPY --from=frontend-assets \/build\/dist\/react\//);
assert.match(dockerfile, /COPY --from=frontend-assets \/build\/dist\/vendor\//);
assert.match(dockerfile, /magistr-security\.conf/);
for (const asset of ['ui-foundation.css', 'schedule-overview.css', 'schedule-overview.js']) {
const copiesAsset = dockerfile.split('\n').some(line =>
line.startsWith('COPY ') && line.includes(asset) && line.includes('/usr/local/apache2/htdocs/')
);
assert.ok(copiesAsset, `Общий ресурс отсутствует в Docker-образе: ${asset}`);
}
assert.match(telemetry, /import\('\/vendor\/otel\.js'\)/);
assert.doesNotMatch(telemetry, /https?:\/\//);
});
test('сообщения об ошибках не вставляют текст исключения через innerHTML', async () => {
const files = [
'react/admin/AdminApp.jsx',
'react/admin/tabs/TeacherRequestsTab.jsx',
'react/admin/tabs/DepartmentWorkspaceTab.jsx',
'react/admin/tabs/AuditoriumWorkloadTab.jsx',
'react/admin/tabs/AcademicCalendarTab.jsx',
'react/settings/SettingsApp.jsx',
'react/settings/DatabaseTab.jsx'
];
for (const relativePath of files) {
const source = await readFile(join(frontendRoot, relativePath), 'utf8');
assert.doesNotMatch(source, /\$\{[^}\n]*(?:e|error|err)\.message[^}\n]*\}/, relativePath);
}
const utils = await readFile(join(frontendRoot, 'admin/js/utils.js'), 'utf8');
assert.match(utils, /element\.textContent = message/);
assert.match(utils, /container\.replaceChildren\(element\)/);
assert.match(utils, /cell\.textContent = message/);
assert.match(utils, /tbody\.replaceChildren\(row\)/);
});
test('пароли скрыты и помечены для менеджера паролей', async () => {
const users = await readFile(join(frontendRoot, 'react/admin/tabs/UsersTab.jsx'), 'utf8');
const databaseTab = await readFile(join(frontendRoot, 'react/settings/DatabaseTab.jsx'), 'utf8');
const teacherRequests = await readFile(join(frontendRoot, 'react/admin/tabs/TeacherRequestsTab.jsx'), 'utf8');
assert.match(users, /id="new-password"[^>]*type="password"|type="password"[^>]*id="new-password"/);
assert.match(users, /id="new-password"[^>]*autoComplete="new-password"/);
assert.match(databaseTab, /id="tenant-password"[\s\S]*?type="password"|type="password"[\s\S]*?id="tenant-password"/);
assert.match(databaseTab, /id="tenant-password"[\s\S]*?autoComplete="new-password"|autoComplete="new-password"[\s\S]*?id="tenant-password"/);
assert.match(teacherRequests, /type="password"[^>]*className="teacher-request-password"[^>]*autoComplete="new-password"/);
});
test('tenant UI и production-логи соблюдают русский языковой регламент', async () => {
const databaseTab = await readFile(join(frontendRoot, 'react/settings/DatabaseTab.jsx'), 'utf8');
assert.doesNotMatch(databaseTab, /\bOnline\b|\bOffline\b/);
assert.match(databaseTab, /Доступно/);
assert.match(databaseTab, /Недоступно/);
const backendRoot = join(frontendRoot, '..', 'backend', 'src', 'main', 'java', 'com', 'magistr', 'app', 'config', 'tenant');
const logSources = await Promise.all([
'TenantRoutingDataSource.java',
'TenantDataSourceConfig.java',
'TenantInterceptor.java'
].map(name => readFile(join(backendRoot, name), 'utf8')));
const tenantSources = logSources.flatMap(source =>
Array.from(source.matchAll(/log\.(?:debug|info|warn|error)\("([^"]*)"/g), match => match[1])
).join('\n');
assert.doesNotMatch(
tenantSources,
/Database API request|Unknown tenant|Resolved tenant|default DataSource|H2 in-memory|errorType|Tenant-БД|tenant-БД|startup lifecycle|startup Hikari pool/
);
const frontendSources = (await sourceCorpus())
.filter(({ path }) => ['.js', '.jsx'].includes(extname(path)))
.map(({ text }) => text)
.join('\n');
assert.doesNotMatch(frontendSources, /console\.(?:log|info|warn|error)\([^\n]*(?:Failed|Error)/);
assert.doesNotMatch(frontendSources, /console\.(?:warn|error)\([^\n]*\.message/);
});
test('единая матрица ролей согласует admin и settings для учебного отдела', async () => {
const {
ADMIN_APP_ROLES,
SETTINGS_ROLES,
capabilitiesForRole
} = await import('../admin/js/role-capabilities.js');
const educationOffice = capabilitiesForRole('EDUCATION_OFFICE');
assert.ok(ADMIN_APP_ROLES.includes('EDUCATION_OFFICE'));
assert.ok(SETTINGS_ROLES.includes('EDUCATION_OFFICE'));
assert.ok(educationOffice.adminTabs.includes('academic-calendar'));
assert.deepEqual(educationOffice.settingsTabs, ['time-slots', 'edu-forms']);
assert.equal(educationOffice.defaultSettingsTab, 'time-slots');
assert.equal(capabilitiesForRole('DEPARTMENT').settingsTabs.length, 0);
const adminService = await readFile(join(frontendRoot, 'react/admin/admin-service.js'), 'utf8');
const settingsApp = await readFile(join(frontendRoot, 'react/settings/SettingsApp.jsx'), 'utf8');
assert.match(adminService, /from '\.\.\/\.\.\/admin\/js\/role-capabilities\.js'/);
assert.match(settingsApp, /from '\.\.\/\.\.\/admin\/js\/role-capabilities\.js'/);
assert.doesNotMatch(adminService, /const ROLE_NAVIGATION/);
assert.doesNotMatch(settingsApp, /const ROLE_TABS/);
});