175 lines
5.6 KiB
JavaScript
175 lines
5.6 KiB
JavaScript
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');
|
||
});
|