баг-фикс 30/34
This commit is contained in:
174
frontend/tests/auth-session.test.mjs
Normal file
174
frontend/tests/auth-session.test.mjs
Normal file
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
let importSequence = 0;
|
||||
|
||||
class StorageMock {
|
||||
constructor(initial = {}) {
|
||||
this.values = new Map(Object.entries(initial));
|
||||
this.removed = [];
|
||||
}
|
||||
|
||||
getItem(key) {
|
||||
return this.values.has(key) ? this.values.get(key) : null;
|
||||
}
|
||||
|
||||
setItem(key, value) {
|
||||
this.values.set(key, String(value));
|
||||
}
|
||||
|
||||
removeItem(key) {
|
||||
this.removed.push(key);
|
||||
this.values.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAuthModule() {
|
||||
importSequence += 1;
|
||||
return import(`../auth-session.js?test=${importSequence}`);
|
||||
}
|
||||
|
||||
function installBrowserMocks() {
|
||||
globalThis.localStorage = new StorageMock({
|
||||
token: 'устаревший-access-token',
|
||||
role: 'ADMIN',
|
||||
departmentId: '7',
|
||||
userId: '11',
|
||||
theme: 'dark'
|
||||
});
|
||||
globalThis.sessionStorage = new StorageMock({ token: 'устаревший-session-token' });
|
||||
}
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
test('login сохраняет access JWT только в памяти и очищает legacy Web Storage', async () => {
|
||||
installBrowserMocks();
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return jsonResponse({ ok: true });
|
||||
};
|
||||
|
||||
const auth = await loadAuthModule();
|
||||
const state = auth.storeAuthState({
|
||||
token: 'access-login',
|
||||
role: 'ADMIN',
|
||||
departmentId: 7,
|
||||
userId: 11,
|
||||
redirect: '/admin/'
|
||||
});
|
||||
|
||||
assert.equal(auth.getAccessToken(), 'access-login');
|
||||
assert.deepEqual(state, {
|
||||
role: 'ADMIN',
|
||||
departmentId: '7',
|
||||
userId: '11',
|
||||
redirect: '/admin/'
|
||||
});
|
||||
assert.equal(localStorage.getItem('token'), null);
|
||||
assert.equal(localStorage.getItem('role'), null);
|
||||
assert.equal(localStorage.getItem('theme'), 'dark');
|
||||
|
||||
await auth.fetchWithAuth('/api/test');
|
||||
assert.equal(new Headers(calls[0].options.headers).get('Authorization'), 'Bearer access-login');
|
||||
});
|
||||
|
||||
test('reload восстанавливает память через HttpOnly refresh-cookie', async () => {
|
||||
installBrowserMocks();
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return jsonResponse({
|
||||
token: 'access-after-reload',
|
||||
role: 'DEPARTMENT',
|
||||
departmentId: 4,
|
||||
userId: 15,
|
||||
redirect: '/admin/#department-workspace'
|
||||
});
|
||||
};
|
||||
|
||||
const auth = await loadAuthModule();
|
||||
const state = await auth.restoreSession();
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, '/api/auth/refresh');
|
||||
assert.equal(calls[0].options.method, 'POST');
|
||||
assert.equal(calls[0].options.credentials, 'same-origin');
|
||||
assert.equal(auth.getAccessToken(), 'access-after-reload');
|
||||
assert.equal(state.role, 'DEPARTMENT');
|
||||
});
|
||||
|
||||
test('401 запускает одну ротацию refresh и повторяет запрос с новым access JWT', async () => {
|
||||
installBrowserMocks();
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
if (calls.length === 1) return jsonResponse({ message: 'Истёк токен' }, 401);
|
||||
if (url === '/api/auth/refresh') {
|
||||
return jsonResponse({ token: 'access-rotated', role: 'ADMIN' });
|
||||
}
|
||||
return jsonResponse({ success: true });
|
||||
};
|
||||
|
||||
const auth = await loadAuthModule();
|
||||
auth.storeAuthState({ token: 'access-old', role: 'ADMIN' });
|
||||
const response = await auth.fetchWithAuth('/api/protected');
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(calls.map(call => call.url), [
|
||||
'/api/protected',
|
||||
'/api/auth/refresh',
|
||||
'/api/protected'
|
||||
]);
|
||||
assert.equal(new Headers(calls[0].options.headers).get('Authorization'), 'Bearer access-old');
|
||||
assert.equal(new Headers(calls[2].options.headers).get('Authorization'), 'Bearer access-rotated');
|
||||
});
|
||||
|
||||
test('параллельные запросы используют одну refresh-ротацию', async () => {
|
||||
installBrowserMocks();
|
||||
let refreshCalls = 0;
|
||||
let releaseRefresh;
|
||||
const refreshGate = new Promise(resolve => {
|
||||
releaseRefresh = resolve;
|
||||
});
|
||||
globalThis.fetch = async url => {
|
||||
if (url === '/api/auth/refresh') {
|
||||
refreshCalls += 1;
|
||||
await refreshGate;
|
||||
return jsonResponse({ token: 'access-shared', role: 'ADMIN' });
|
||||
}
|
||||
return jsonResponse({ success: true });
|
||||
};
|
||||
|
||||
const auth = await loadAuthModule();
|
||||
const first = auth.refreshAccessToken();
|
||||
const second = auth.refreshAccessToken();
|
||||
releaseRefresh();
|
||||
|
||||
assert.equal(await first, true);
|
||||
assert.equal(await second, true);
|
||||
assert.equal(refreshCalls, 1);
|
||||
});
|
||||
|
||||
test('logout отзывает cookie-сессию и очищает access JWT из памяти', async () => {
|
||||
installBrowserMocks();
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return jsonResponse({ success: true });
|
||||
};
|
||||
|
||||
const auth = await loadAuthModule();
|
||||
auth.storeAuthState({ token: 'access-before-logout', role: 'STUDENT', userId: 22 });
|
||||
await auth.logoutSession();
|
||||
|
||||
assert.equal(auth.getAccessToken(), null);
|
||||
assert.equal(auth.getAuthState(), null);
|
||||
assert.equal(calls[0].url, '/api/auth/logout');
|
||||
assert.equal(calls[0].options.credentials, 'same-origin');
|
||||
});
|
||||
18
frontend/tests/local-date-format.test.mjs
Normal file
18
frontend/tests/local-date-format.test.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
process.env.TZ = 'Europe/Moscow';
|
||||
|
||||
const { formatLocalDate } = await import('../admin/js/utils.js');
|
||||
|
||||
test('date-only форматируется по московским компонентам, а не через UTC', () => {
|
||||
assert.equal(formatLocalDate(new Date('2026-01-01T21:15:00Z')), '2026-01-02');
|
||||
});
|
||||
|
||||
test('date-only сохраняет локальный день до московской полуночи', () => {
|
||||
assert.equal(formatLocalDate(new Date('2026-01-01T20:59:59Z')), '2026-01-01');
|
||||
});
|
||||
|
||||
test('некорректная дата отклоняется явной русской ошибкой', () => {
|
||||
assert.throws(() => formatLocalDate(new Date('invalid')), /Ожидалась корректная дата/);
|
||||
});
|
||||
169
frontend/tests/security-policy.test.mjs
Normal file
169
frontend/tests/security-policy.test.mjs
Normal file
@@ -0,0 +1,169 @@
|
||||
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', '.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:vendor/);
|
||||
assert.match(dockerfile, /scripts\/build-vendor\.mjs/);
|
||||
assert.match(dockerfile, /COPY --from=frontend-assets \/build\/dist\/vendor\//);
|
||||
assert.match(dockerfile, /magistr-security\.conf/);
|
||||
assert.match(telemetry, /import\('\/vendor\/otel\.js'\)/);
|
||||
assert.doesNotMatch(telemetry, /https?:\/\//);
|
||||
});
|
||||
|
||||
test('сообщения об ошибках не вставляют текст исключения через innerHTML', async () => {
|
||||
const files = [
|
||||
'admin/js/main.js',
|
||||
'admin/settings/js/main.js',
|
||||
'admin/settings/js/views/database.js',
|
||||
'admin/js/views/teacher-requests.js'
|
||||
];
|
||||
|
||||
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, 'admin/views/users.html'), 'utf8');
|
||||
const database = await readFile(join(frontendRoot, 'admin/settings/views/database.html'), 'utf8');
|
||||
const teacherRequests = await readFile(join(frontendRoot, 'admin/js/views/teacher-requests.js'), '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(database, /id="tenant-password"[^>]*type="password"|type="password"[^>]*id="tenant-password"/);
|
||||
assert.match(database, /id="tenant-password"[^>]*autocomplete="new-password"/);
|
||||
assert.match(teacherRequests, /type="password"[^>]*class="teacher-request-password"[^>]*autocomplete="new-password"/);
|
||||
});
|
||||
|
||||
test('tenant UI и production-логи соблюдают русский языковой регламент', async () => {
|
||||
const database = await readFile(join(frontendRoot, 'admin/settings/js/views/database.js'), 'utf8');
|
||||
assert.doesNotMatch(database, /\bOnline\b|\bOffline\b/);
|
||||
assert.match(database, /Доступно/);
|
||||
assert.match(database, /Недоступно/);
|
||||
|
||||
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 }) => extname(path) === '.js')
|
||||
.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 adminMain = await readFile(join(frontendRoot, 'admin/js/main.js'), 'utf8');
|
||||
const settingsMain = await readFile(join(frontendRoot, 'admin/settings/js/main.js'), 'utf8');
|
||||
assert.match(adminMain, /from '\.\/role-capabilities\.js'/);
|
||||
assert.match(settingsMain, /from '\.\.\/\.\.\/js\/role-capabilities\.js'/);
|
||||
assert.doesNotMatch(adminMain, /const ROLE_NAVIGATION/);
|
||||
assert.doesNotMatch(settingsMain, /const ROLE_TABS/);
|
||||
});
|
||||
Reference in New Issue
Block a user