You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
625 lines
25 KiB
625 lines
25 KiB
/**
|
|
* TokenManager 单元测试
|
|
*
|
|
* 覆盖重点是竞态与自激循环 —— 这些是线上最难复现、最容易写错的部分,
|
|
* 必须用假定时器 + 假时钟才能真正验证(真实等待 270 秒不现实)。
|
|
* token.ts 不碰 DOM,因此 vitest 的 node 环境即可运行。
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import {
|
|
configureTokenManager,
|
|
setHostToken,
|
|
getToken,
|
|
hasProvider,
|
|
ensureFresh,
|
|
refreshNow,
|
|
forceRefresh,
|
|
resetTokenManager,
|
|
isProtectedPath,
|
|
} from '../src/token';
|
|
import { logger } from '../src/logger';
|
|
|
|
/** 从「现在」起推进假时钟(同时触发期间到期的定时器、并冲刷微任务) */
|
|
const tick = (ms: number) => vi.advanceTimersByTimeAsync(ms);
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
|
|
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
|
|
resetTokenManager();
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetTokenManager();
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('isProtectedPath - 与后端 SdkAuthFilter 对齐', () => {
|
|
const cases: Array<[string, boolean]> = [
|
|
// 受保护:/ai/**(后端 SdkAuthFilter 拦截)
|
|
['http://localhost:9090/ai/chat?message=hi', true],
|
|
['http://localhost:9090/ai/chat/stream?message=hi', true],
|
|
['http://localhost:9090/ai/sdk/conversation/list', true],
|
|
['http://localhost:9090/ai/conversation/123/messages', true],
|
|
// 显式放行:/ai/system-config/**(SecurityConfig 中 permitAll,不该带 Token)
|
|
['http://localhost:9090/ai/system-config/disclaimer', false],
|
|
// 受保护:无尾斜杠写法与 /feedback、/attachment/upload
|
|
['http://localhost:9090/feedback', true],
|
|
['http://localhost:9090/feedback?x=1', true],
|
|
['http://localhost:9090/attachment/upload', true],
|
|
// 不受保护
|
|
['http://localhost:9090/category/tree', false],
|
|
['http://localhost:9090/category/list', false],
|
|
['http://localhost:9090/conversation/list', false],
|
|
// 路径里只是「含」/ai/ 但实际不在 /ai/ 下时不能误判:
|
|
// 旧实现用 includes('/ai/'),会把 SDK Token 白白发给非 SDK 端点
|
|
['http://localhost:9090/conversation/list?ref=/ai/chat', false],
|
|
// 无法解析的 URL 一律视为不受保护
|
|
['not a url', false],
|
|
];
|
|
|
|
it.each(cases)('%s → %s', (url, expected) => {
|
|
expect(isProtectedPath(url)).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe('临期主动刷新', () => {
|
|
it('expiresIn=20 时约在 18 秒后自动刷新(skew = 总寿命的 10%)', async () => {
|
|
const provider = vi.fn(async () => ({ token: 'token-2', expiresIn: 20 }));
|
|
configureTokenManager({ token: 'token-1', expiresIn: 20, getToken: provider });
|
|
|
|
await tick(17_000);
|
|
expect(provider).not.toHaveBeenCalled(); // 未到临期点,不该提前打扰 provider
|
|
|
|
await tick(1_000);
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
expect(getToken()).toBe('token-2');
|
|
});
|
|
|
|
it('expiresIn=300 时临期阈值自适应为 30s,不会退化成每次请求都刷新', async () => {
|
|
let calls = 0;
|
|
const provider = vi.fn(async () => {
|
|
calls++;
|
|
return { token: `token-${calls}`, expiresIn: 300 };
|
|
});
|
|
configureTokenManager({ token: 'token-0', expiresIn: 300, getToken: provider });
|
|
|
|
// 后端允许的最短 ttl 恰好是 300s。若临期阈值固定为 5 分钟,
|
|
// 300s 的 Token 会永远处于临期态 —— 定时器延迟算成 0,形成秒级无限刷新。
|
|
await tick(271_000);
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
|
|
// 刷新后 Token 是全新的,连续 10 次请求前检查都不该再次刷新
|
|
for (let i = 0; i < 10; i++) await ensureFresh();
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
|
|
// 再推进到第二个临期点:只应多刷新一次,而不是成百上千次
|
|
await tick(300_000);
|
|
expect(provider).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('未配置 getToken 时不排定时器,也不因临期而报错', async () => {
|
|
configureTokenManager({ token: 'token-1', expiresIn: 300 });
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
await tick(271_000);
|
|
await ensureFresh();
|
|
expect(getToken()).toBe('token-1');
|
|
});
|
|
|
|
it('未提供 expiresIn 时不排定时器(只能靠 401 驱动)', () => {
|
|
const provider = vi.fn(async () => 'whatever');
|
|
configureTokenManager({ token: 'token-1', getToken: provider });
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
expect(hasProvider()).toBe(true);
|
|
});
|
|
|
|
it('连成功刷新但 provider 返回同一个 Token 时,静默窗口内不再重复调用', async () => {
|
|
const provider = vi.fn(async () => 'same-token');
|
|
configureTokenManager({ token: 'same-token', expiresIn: 300, getToken: provider });
|
|
|
|
await tick(271_000); // 定时器触发一次刷新
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
expect(getToken()).toBe('same-token');
|
|
|
|
// provider 换了半天还是同一个 Token,说明它已失效:静默窗口内不再打扰
|
|
await refreshNow('request');
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('自主刷新连续失败 3 次后停掉定时器链', async () => {
|
|
const provider = vi.fn(async () => {
|
|
throw new Error('provider 挂了');
|
|
});
|
|
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
|
|
configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider });
|
|
|
|
await tick(271_000); // 第 1 次失败
|
|
await tick(61_000); // 退避 60s 后第 2 次
|
|
await tick(61_000); // 第 3 次
|
|
const afterThree = provider.mock.calls.length;
|
|
expect(afterThree).toBe(3);
|
|
|
|
// 已达上限:定时器链停止,不再有任何自主调用
|
|
await tick(600_000);
|
|
expect(provider).toHaveBeenCalledTimes(afterThree);
|
|
});
|
|
});
|
|
|
|
describe('single-flight 与并发', () => {
|
|
it('并发请求共享同一次刷新,provider 只被调用一次', async () => {
|
|
let settleProvider!: (value: unknown) => void;
|
|
const provider = vi.fn(() => new Promise((resolve) => { settleProvider = resolve; }));
|
|
// expiresIn=2 → 临期阈值 200ms → 定时器在 1800ms 触发自主刷新
|
|
configureTokenManager({ token: 'token-0', expiresIn: 2, getToken: provider });
|
|
|
|
await tick(1_800);
|
|
expect(provider).toHaveBeenCalledTimes(1); // 自主刷新已在途(provider 尚未 settle)
|
|
|
|
// 刷新在途期间三个并发请求,都应搭上同一次刷新而不是各发一次
|
|
const inFlight = [ensureFresh(), ensureFresh(), ensureFresh()];
|
|
settleProvider({ token: 'token-1', expiresIn: 2 });
|
|
await Promise.all(inFlight);
|
|
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
expect(getToken()).toBe('token-1');
|
|
});
|
|
|
|
it('自主刷新有最小间隔节流,不会在同一个窗口内反复打点', async () => {
|
|
const provider = vi.fn(async () => ({ token: `t-${Date.now()}`, expiresIn: 300 }));
|
|
configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider });
|
|
|
|
await refreshNow('timer');
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
|
|
// 5 秒内再次请求自主刷新 → 被节流
|
|
await tick(1_000);
|
|
await refreshNow('timer');
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
|
|
await tick(5_000);
|
|
await refreshNow('timer');
|
|
expect(provider).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('provider 返回值与异常处理', () => {
|
|
const invalidProviders: Array<[string, () => unknown]> = [
|
|
['空字符串', () => ''],
|
|
['纯空白字符串', () => ' '],
|
|
['null', () => null],
|
|
['数字', () => 123],
|
|
['缺少 token 字段的对象', () => ({ expiresIn: 300 })],
|
|
['token 为空串的对象', () => ({ token: '' })],
|
|
['同步抛错', () => { throw new Error('boom'); }],
|
|
];
|
|
|
|
it.each(invalidProviders)('provider 返回%s时保持原 Token 不变', async (_name, impl) => {
|
|
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
|
|
configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: impl as never });
|
|
|
|
await refreshNow('request');
|
|
// 绝不能变成空串 —— 那会让请求带上「Bearer 」空值,白跑一次往返
|
|
expect(getToken()).toBe('token-ok');
|
|
});
|
|
|
|
it('provider 返回 reject 时保持原 Token 不变', async () => {
|
|
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
|
|
const provider = vi.fn(async () => {
|
|
throw new Error('网络不可达');
|
|
});
|
|
configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: provider });
|
|
|
|
await refreshNow('request');
|
|
expect(getToken()).toBe('token-ok');
|
|
});
|
|
|
|
it('provider 永不 settle 时有超时兜底,不会挂起整个 SDK', async () => {
|
|
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
|
|
const provider = vi.fn(() => new Promise(() => { /* 永不 settle */ }));
|
|
configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: provider });
|
|
|
|
const pending = refreshNow('request');
|
|
await tick(10_000); // PROVIDER_TIMEOUT_MS
|
|
await expect(pending).resolves.toBeNull();
|
|
expect(getToken()).toBe('token-ok');
|
|
});
|
|
|
|
it('刷新失败后刷新锁被清空,且退避窗口一过就能成功刷新', async () => {
|
|
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
|
|
let shouldFail = true;
|
|
const provider = vi.fn(async () => {
|
|
if (shouldFail) throw new Error('首次失败');
|
|
return { token: 'token-new', expiresIn: 300 };
|
|
});
|
|
configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: provider });
|
|
|
|
await expect(refreshNow('request')).resolves.toBeNull();
|
|
|
|
// 退避窗口内(60s):即使 provider 已恢复也不再尝试,先安静下来
|
|
shouldFail = false;
|
|
await expect(refreshNow('request')).resolves.toBeNull();
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
|
|
// 退避窗口过后:刷新锁确实被清空了,能正常成功
|
|
await tick(60_000);
|
|
await expect(refreshNow('request')).resolves.toBe('token-new');
|
|
expect(getToken()).toBe('token-new');
|
|
});
|
|
|
|
it('失败上报有 30 秒同码冷却,且退避窗口过后可再次上报', async () => {
|
|
const spy = vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
|
|
const provider = vi.fn(async () => {
|
|
throw new Error('provider 挂了');
|
|
});
|
|
configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: provider });
|
|
|
|
await refreshNow('request');
|
|
await refreshNow('request');
|
|
await refreshNow('request');
|
|
expect(spy).toHaveBeenCalledTimes(1); // 同码冷却 + 退避窗口内不再尝试
|
|
expect(spy).toHaveBeenLastCalledWith(expect.any(String), expect.anything(), 'auth_refresh_failed');
|
|
|
|
// 60s 后同时越过「退避窗口(60s)」与「上报冷却(30s)」,可以再试一次并再次上报
|
|
await tick(60_000);
|
|
await refreshNow('request');
|
|
expect(spy).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('宿主主动更新 Token', () => {
|
|
it('setToken 立即生效,并按新有效期重排主动刷新', async () => {
|
|
const provider = vi.fn(async () => ({ token: 'token-from-provider', expiresIn: 300 }));
|
|
configureTokenManager({ token: 'token-old', expiresIn: 300, getToken: provider });
|
|
|
|
setHostToken('token-host', 7200);
|
|
expect(getToken()).toBe('token-host');
|
|
|
|
// 新有效期 7200s、临期阈值封顶 5 分钟 → 临期点 6900s
|
|
await tick(6_890_000);
|
|
expect(provider).not.toHaveBeenCalled();
|
|
await tick(10_000);
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
expect(getToken()).toBe('token-from-provider');
|
|
});
|
|
|
|
it('刷新在途时 setToken,在途返回的旧值不会覆盖宿主刚推来的新值', async () => {
|
|
let settleProvider!: (value: unknown) => void;
|
|
const provider = vi.fn(() => new Promise((resolve) => { settleProvider = resolve; }));
|
|
configureTokenManager({ token: 'token-old', expiresIn: 300, getToken: provider });
|
|
|
|
const pending = refreshNow('request');
|
|
setHostToken('token-host', 300); // 宿主推送新值
|
|
settleProvider('token-from-provider'); // 在途刷新带着「旧」结果返回
|
|
await pending;
|
|
|
|
expect(getToken()).toBe('token-host');
|
|
});
|
|
|
|
it('setToken 后定时器只保留一个(不会因为重复排期而叠加)', () => {
|
|
const provider = vi.fn(async () => 'x');
|
|
configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider });
|
|
expect(vi.getTimerCount()).toBe(1);
|
|
|
|
setHostToken('token-2', 300);
|
|
setHostToken('token-3', 300);
|
|
expect(vi.getTimerCount()).toBe(1);
|
|
expect(getToken()).toBe('token-3');
|
|
});
|
|
|
|
it('传入无效 Token 时忽略并保持原值', () => {
|
|
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
|
|
configureTokenManager({ token: 'token-ok', expiresIn: 300 });
|
|
setHostToken('');
|
|
setHostToken(' ');
|
|
expect(getToken()).toBe('token-ok');
|
|
});
|
|
|
|
it('refreshToken() 仅在真的换到不同 Token 时返回 true', async () => {
|
|
let next = 'token-1';
|
|
configureTokenManager({ token: 'token-1', getToken: async () => next });
|
|
|
|
await expect(forceRefresh()).resolves.toBe(false); // 换回来的还是同一个
|
|
next = 'token-2';
|
|
await expect(forceRefresh()).resolves.toBe(true);
|
|
expect(getToken()).toBe('token-2');
|
|
});
|
|
|
|
it('未配置 getToken 时 refreshToken() 返回 false 而不报错', async () => {
|
|
configureTokenManager({ token: 'token-1', expiresIn: 300 });
|
|
await expect(forceRefresh()).resolves.toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('destroy 后的状态清理', () => {
|
|
it('清空 Token 与定时器,且在途刷新结算后不会复活状态', async () => {
|
|
let settleProvider!: (value: unknown) => void;
|
|
const provider = vi.fn(() => new Promise((resolve) => { settleProvider = resolve; }));
|
|
configureTokenManager({ token: 'token-old', expiresIn: 300, getToken: provider });
|
|
expect(vi.getTimerCount()).toBe(1); // 主动刷新定时器
|
|
|
|
const pending = refreshNow('request');
|
|
resetTokenManager();
|
|
expect(getToken()).toBeNull();
|
|
|
|
settleProvider({ token: 'token-late', expiresIn: 300 });
|
|
await pending;
|
|
|
|
// 在途结果必须被丢弃,且不能重新排上主动刷新定时器
|
|
expect(getToken()).toBeNull();
|
|
await tick(600_000); // 推进远超临期点
|
|
expect(provider).toHaveBeenCalledTimes(1); // 只有那一次在途调用,没有新的自主刷新
|
|
expect(getToken()).toBeNull();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('destroy 后 ensureFresh / refreshNow / setToken 均为安全空操作', async () => {
|
|
await expect(ensureFresh()).resolves.toBeUndefined();
|
|
await expect(refreshNow('request')).resolves.toBeNull();
|
|
setHostToken('token-after-destroy'); // 不应抛错,也不该写入
|
|
expect(getToken()).toBeNull();
|
|
});
|
|
|
|
it('重新 configure 后可正常重入(destroy → init 场景)', async () => {
|
|
const provider = vi.fn(async () => ({ token: 'token-2', expiresIn: 300 }));
|
|
configureTokenManager({ token: 'token-1', expiresIn: 300 });
|
|
resetTokenManager();
|
|
|
|
configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider });
|
|
await tick(271_000);
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
expect(getToken()).toBe('token-2');
|
|
});
|
|
});
|
|
|
|
describe('配置项健壮性', () => {
|
|
it('expiresIn 超过 24 小时上限时按上限钳制(防 setTimeout 溢出与单位误传)', async () => {
|
|
const spy = vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
|
|
const provider = vi.fn(async () => ({ token: 'token-2', expiresIn: 300 }));
|
|
// 7200000 是把「毫秒」误当「秒」传的典型值
|
|
configureTokenManager({ token: 'token-1', expiresIn: 7_200_000, getToken: provider });
|
|
|
|
expect(spy).toHaveBeenCalledWith(expect.any(String), undefined, 'config_invalid');
|
|
|
|
// 上限 86400s、临期阈值封顶 5 分钟 → 临期点 86100s(而不是 7200000s)
|
|
await tick(86_000_000);
|
|
expect(provider).not.toHaveBeenCalled();
|
|
await tick(100_000);
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('expiresIn 为 0 或负数时视为无过期信息(不排定时器)', () => {
|
|
const provider = vi.fn(async () => 'x');
|
|
configureTokenManager({ token: 'token-1', expiresIn: 0, getToken: provider });
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
|
|
configureTokenManager({ token: 'token-1', expiresIn: -5, getToken: provider });
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('token 为空白串时视为未配置(等首个受保护请求自举)', () => {
|
|
const provider = vi.fn(async () => 'x');
|
|
configureTokenManager({ token: ' ', expiresIn: 300, getToken: provider });
|
|
expect(getToken()).toBeNull();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
expect(hasProvider()).toBe(true);
|
|
});
|
|
|
|
it('自举:只配 getToken 不配 token 时,请求前检查即换来 Token', async () => {
|
|
const provider = vi.fn(async () => ({ token: 'bootstrapped', expiresIn: 300 }));
|
|
configureTokenManager({ getToken: provider });
|
|
expect(getToken()).toBeNull();
|
|
|
|
await ensureFresh();
|
|
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
expect(getToken()).toBe('bootstrapped');
|
|
});
|
|
|
|
it('自举失败时不抛错,保持无 Token 状态', async () => {
|
|
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
|
|
const provider = vi.fn(async () => {
|
|
throw new Error('宿主后端不可用');
|
|
});
|
|
configureTokenManager({ getToken: provider });
|
|
|
|
await expect(ensureFresh()).resolves.toBeUndefined();
|
|
expect(getToken()).toBeNull();
|
|
});
|
|
|
|
it('刷新失败后的退避窗口内,不再让每个请求都多等一次刷新', async () => {
|
|
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
|
|
const provider = vi.fn(async () => {
|
|
throw new Error('宿主后端挂了');
|
|
});
|
|
// 无 token → 每次 ensureFresh 都会尝试自举,最容易暴露「每个请求都等一次超时」的问题
|
|
configureTokenManager({ getToken: provider });
|
|
|
|
await ensureFresh();
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
|
|
// 退避窗口内:请求前检查直接放行,不再等刷新(否则 provider 挂死时每个请求都要多等 10s)
|
|
await ensureFresh();
|
|
await ensureFresh();
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
|
|
// 退避窗口过后恢复尝试
|
|
await tick(60_000);
|
|
await ensureFresh();
|
|
expect(provider).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('configureTokenManager 会清空上一轮状态(重复 init 不叠加)', () => {
|
|
const provider = vi.fn(async () => 'x');
|
|
configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider });
|
|
configureTokenManager({ token: 'token-2', expiresIn: 300 });
|
|
expect(getToken()).toBe('token-2');
|
|
expect(hasProvider()).toBe(false);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("tokenStrategy: 'always' - 每请求前都向宿主取一次", () => {
|
|
it('Token 新鲜时仍会取(SDK 不推算过期时间)', async () => {
|
|
const provider = vi.fn(async () => ({ token: 'token-new', expiresIn: 7200 }));
|
|
configureTokenManager({ token: 'token-old', expiresIn: 7200, getToken: provider, tokenStrategy: 'always' });
|
|
|
|
await ensureFresh();
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
expect(getToken()).toBe('token-new');
|
|
|
|
// 第二次请求前同样会取 —— 这正是该模式与 'expiry' 的核心差别
|
|
await ensureFresh();
|
|
expect(provider).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('provider 反复返回同一 Token 也每次照取(静默窗口在该模式不生效)', async () => {
|
|
const provider = vi.fn(async () => 'token-ok');
|
|
configureTokenManager({ token: 'token-ok', getToken: provider, tokenStrategy: 'always' });
|
|
|
|
await ensureFresh();
|
|
await ensureFresh();
|
|
await ensureFresh();
|
|
// 'expiry' 模式下第二次就会被静默窗口拦下;'always' 模式下宿主返回缓存 Token 是常态,不该拦
|
|
expect(provider).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('不排定时器:该模式不推算过期时间', async () => {
|
|
const provider = vi.fn(async () => ({ token: 'x', expiresIn: 300 }));
|
|
configureTokenManager({ token: 'token-1', expiresIn: 300, getToken: provider, tokenStrategy: 'always' });
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
|
|
// 推进远超有效期的时长,也不该有自主刷新发生
|
|
await tick(10_000_000);
|
|
expect(provider).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('不传 expiresIn 也能每请求前取(这正是要解决的缺口)', async () => {
|
|
const provider = vi.fn(async () => ({ token: 'token-new' }));
|
|
configureTokenManager({ getToken: provider, tokenStrategy: 'always' });
|
|
|
|
await ensureFresh();
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
expect(getToken()).toBe('token-new');
|
|
});
|
|
|
|
it('取 Token 失败时沿用现有 Token 继续,且退避窗口内不再取', async () => {
|
|
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
|
|
const provider = vi.fn(async () => {
|
|
throw new Error('宿主后端挂了');
|
|
});
|
|
configureTokenManager({ token: 'token-ok', getToken: provider, tokenStrategy: 'always' });
|
|
|
|
await ensureFresh();
|
|
// 取不到新 Token 不该把旧 Token 清掉 —— 请求照常发出,由 401 兜底
|
|
expect(getToken()).toBe('token-ok');
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
|
|
await ensureFresh();
|
|
expect(provider).toHaveBeenCalledTimes(1); // 熔断窗口内不再取
|
|
|
|
await tick(60_000);
|
|
await ensureFresh();
|
|
expect(provider).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('tokenUrl - 用地址代替 getToken 回调', () => {
|
|
/** 构造返回给定响应的 fetch stub */
|
|
const stubFetch = (payload: unknown, opts: { ok?: boolean; status?: number } = {}) => {
|
|
const fn = vi.fn(async () => ({
|
|
ok: opts.ok ?? true,
|
|
status: opts.status ?? 200,
|
|
json: async () => payload,
|
|
}));
|
|
vi.stubGlobal('fetch', fn);
|
|
return fn;
|
|
};
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('解析扁平的 { token, expiresIn } 响应,且用 same-origin 取 Cookie', async () => {
|
|
const fetchMock = stubFetch({ token: 'token-from-url', expiresIn: 3600 });
|
|
configureTokenManager({ token: 'token-old', tokenUrl: 'https://host.example.com/api/sdk-token' });
|
|
|
|
await refreshNow('request');
|
|
|
|
expect(getToken()).toBe('token-from-url');
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://host.example.com/api/sdk-token',
|
|
expect.objectContaining({ method: 'GET', credentials: 'same-origin' })
|
|
);
|
|
});
|
|
|
|
it('解析 { success, token, expiresIn, roles } 封装响应', async () => {
|
|
stubFetch({ success: true, token: 'token-wrapped', expiresIn: 7200, roles: [{ id: '1', name: '客服' }] });
|
|
configureTokenManager({ token: 'token-old', tokenUrl: '/api/sdk-token' });
|
|
|
|
await refreshNow('request');
|
|
|
|
expect(getToken()).toBe('token-wrapped');
|
|
});
|
|
|
|
it('HTTP 非 2xx 时视为刷新失败,保持原 Token', async () => {
|
|
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
|
|
stubFetch({ message: 'not found' }, { ok: false, status: 404 });
|
|
configureTokenManager({ token: 'token-old', tokenUrl: '/api/sdk-token' });
|
|
|
|
await expect(refreshNow('request')).resolves.toBeNull();
|
|
expect(getToken()).toBe('token-old');
|
|
});
|
|
|
|
it('响应缺少 token 字段时视为刷新失败,保持原 Token', async () => {
|
|
vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
|
|
stubFetch({ success: true });
|
|
configureTokenManager({ token: 'token-old', tokenUrl: '/api/sdk-token' });
|
|
|
|
await expect(refreshNow('request')).resolves.toBeNull();
|
|
expect(getToken()).toBe('token-old');
|
|
});
|
|
|
|
it('与 getToken 同时配置时 getToken 优先,不请求 tokenUrl', async () => {
|
|
const fetchMock = stubFetch({ token: 'never-used' });
|
|
const provider = vi.fn(async () => ({ token: 'token-from-callback' }));
|
|
configureTokenManager({ token: 'token-old', getToken: provider, tokenUrl: '/api/sdk-token' });
|
|
|
|
await refreshNow('request');
|
|
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(getToken()).toBe('token-from-callback');
|
|
});
|
|
|
|
it('配合 always 策略时可做到每请求前都走 URL 取 Token', async () => {
|
|
const fetchMock = stubFetch({ token: 'token-from-url', expiresIn: 7200 });
|
|
configureTokenManager({ token: 'token-old', tokenUrl: '/api/sdk-token', tokenStrategy: 'always' });
|
|
|
|
await ensureFresh();
|
|
await ensureFresh();
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('forceRefresh 绕过自动触发的两道闸', () => {
|
|
it('能绕过刷新失败后的退避(熔断)窗口', async () => {
|
|
const spy = vi.spyOn(logger, 'error').mockImplementation(() => { /* 静默 */ });
|
|
let shouldFail = true;
|
|
const provider = vi.fn(async () => {
|
|
if (shouldFail) throw new Error('先失败一次');
|
|
return { token: 'token-new', expiresIn: 300 };
|
|
});
|
|
configureTokenManager({ token: 'token-ok', expiresIn: 300, getToken: provider });
|
|
|
|
await refreshNow('request'); // 失败 → 进入 60s 退避
|
|
expect(spy).toHaveBeenCalledWith(expect.any(String), expect.anything(), 'auth_refresh_failed');
|
|
|
|
shouldFail = false;
|
|
// 自动路径此时会被熔断拦下,但宿主显式要求刷新应当放行
|
|
await expect(refreshNow('request')).resolves.toBeNull();
|
|
await expect(forceRefresh()).resolves.toBe(true);
|
|
expect(getToken()).toBe('token-new');
|
|
});
|
|
});
|