4182979a19
- 原生会话快照拆成身份代次与写入 revision,安装与清除按 revision 排序、按身份归属判定 - 冻结平台会话校验改为只比身份,同账号 access token 轮换不再让在途生成、编辑、上传、确认和下载失败 - 同一身份代次禁止更换登录主体或服务 origin,换号、退出和 origin 变化继续失败关闭 - Runner attach 新增 platform_auth_revision,平台会话安装与校验按 revision 排序、按身份归属判定 - GUI owner 替换与清除改为计数器只增不减,同一主体重装保持身份代次,避免旧 epoch 迟到写入复活 - renderer 拆分平台原生身份代次与写入 revision,同账号续期只更新凭据,不推进身份代次 - 刷新失败结果新增权威失效判定,只有服务端明确 401 与 403 才清除本地会话,网络错误和 5xx 保留会话 - 收敛刷新判据,重复的刷新工具函数合并为一个对外判定,去掉一次性包装 - 补齐平台会话身份判据、续期与迟到写入的 Rust 与前端回归用例
284 lines
9.4 KiB
TypeScript
284 lines
9.4 KiB
TypeScript
/** @vitest-environment jsdom */
|
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
|
|
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
|
|
import {
|
|
requestClientApi,
|
|
setStoredAuthAccessToken,
|
|
} from '../src/services/clientApi';
|
|
import {
|
|
getClientAuthRefreshOperation,
|
|
getStoredAuthAccessToken,
|
|
refreshClientAuthAccessToken,
|
|
} from '../src/services/clientAuth';
|
|
import { CLIENT_HTTP_DEFAULT_TIMEOUT_MS } from '../src/services/clientHttp';
|
|
import {
|
|
cachedLlmModelCatalog,
|
|
refreshLlmModelCatalog,
|
|
resetLlmModelCatalogCacheForTest,
|
|
} from '../src/services/llmModelCatalog';
|
|
import {
|
|
beginPlatformSessionTransition,
|
|
commitAuthenticatedPlatformSession,
|
|
currentPlatformSessionGeneration,
|
|
resetPlatformSessionStateForTests,
|
|
} from '../src/services/platformSession';
|
|
|
|
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
|
|
vi.mock(
|
|
'../../../packages/shared/src',
|
|
() => import('../../../packages/shared/src/http'),
|
|
);
|
|
vi.mock('../src/services/errorReporting', () => ({
|
|
captureClientError: vi.fn(),
|
|
}));
|
|
|
|
const user = { id: 'session-user' } as AuthUser;
|
|
const nativeInvoke = vi.fn(async () => null);
|
|
const catalog = { models: [{ id: 'quality', displayName: '高质量' }] };
|
|
const json = (value: unknown, status = 200) =>
|
|
new Response(JSON.stringify(value), { status });
|
|
|
|
beforeEach(async () => {
|
|
resetLlmModelCatalogCacheForTest();
|
|
resetPlatformSessionStateForTests();
|
|
window.localStorage.clear();
|
|
nativeInvoke.mockClear();
|
|
window.__TAURI__ = { core: { invoke: nativeInvoke } };
|
|
setStoredAuthAccessToken('expired-token');
|
|
await commitAuthenticatedPlatformSession(
|
|
user,
|
|
currentPlatformSessionGeneration(),
|
|
);
|
|
nativeInvoke.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
resetLlmModelCatalogCacheForTest();
|
|
resetPlatformSessionStateForTests();
|
|
window.localStorage.clear();
|
|
delete window.__TAURI__;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it.each([200, 503])(
|
|
'模型目录 HTTP %s 响应体卡住后超时,保留缓存且能再次刷新',
|
|
async (status) => {
|
|
vi.useFakeTimers();
|
|
const previous = { ...catalog, defaultModelId: 'quality', revision: 1 };
|
|
const updated = {
|
|
defaultModelId: 'fast',
|
|
models: [{ id: 'fast', displayName: '快速' }],
|
|
revision: 2,
|
|
};
|
|
let body!: ReadableStreamDefaultController<Uint8Array>;
|
|
const stalledResponse = new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
body = controller;
|
|
},
|
|
}),
|
|
{ status },
|
|
);
|
|
const fetch = vi
|
|
.spyOn(globalThis, 'fetch')
|
|
.mockResolvedValueOnce(json(previous))
|
|
.mockResolvedValueOnce(stalledResponse)
|
|
.mockResolvedValueOnce(json(updated));
|
|
await expect(refreshLlmModelCatalog()).resolves.toEqual(previous);
|
|
let failure: unknown;
|
|
const pending = refreshLlmModelCatalog().catch((error: unknown) => {
|
|
failure = error;
|
|
});
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(CLIENT_HTTP_DEFAULT_TIMEOUT_MS);
|
|
expect(failure).toMatchObject({ code: 'CLIENT_HTTP_TIMEOUT' });
|
|
await pending;
|
|
expect(cachedLlmModelCatalog()).toEqual(previous);
|
|
await expect(refreshLlmModelCatalog()).resolves.toEqual(updated);
|
|
expect(fetch).toHaveBeenCalledTimes(3);
|
|
} finally {
|
|
// 迟到的响应不能在新刷新完成后覆盖缓存,同时释放测试流。
|
|
body.enqueue(new TextEncoder().encode(JSON.stringify(previous)));
|
|
body.close();
|
|
await pending;
|
|
}
|
|
expect(cachedLlmModelCatalog()).toEqual(updated);
|
|
},
|
|
);
|
|
|
|
it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token 重试', async () => {
|
|
let refreshCalls = 0;
|
|
let modelCalls = 0;
|
|
const fetch = vi
|
|
.spyOn(globalThis, 'fetch')
|
|
.mockImplementation(async (input, init) => {
|
|
if (input === '/api/auth/refresh') {
|
|
refreshCalls += 1;
|
|
return json({ token: 'fresh-token' });
|
|
}
|
|
if (input === '/api/auth/me') return json({ user });
|
|
modelCalls += 1;
|
|
const token = new Headers(init?.headers).get('Authorization');
|
|
if (token === 'Bearer expired-token') return json({}, 401);
|
|
expect(token).toBe('Bearer fresh-token');
|
|
expect(nativeInvoke).toHaveBeenCalledWith(
|
|
'install_platform_account_session',
|
|
expect.objectContaining({
|
|
accessToken: 'fresh-token',
|
|
userId: user.id,
|
|
}),
|
|
);
|
|
return json(catalog);
|
|
});
|
|
|
|
const results = await Promise.all([
|
|
requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'),
|
|
requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'),
|
|
]);
|
|
expect(results).toEqual([catalog, catalog]);
|
|
expect(refreshCalls).toBe(1);
|
|
expect(modelCalls).toBe(4);
|
|
expect(fetch).toHaveBeenCalledTimes(6);
|
|
});
|
|
|
|
it.each([401])(
|
|
'续期被明确拒绝时保留原 HTTP %s,且不重发业务请求',
|
|
async (status) => {
|
|
let refreshCalls = 0;
|
|
const fetch = vi
|
|
.spyOn(globalThis, 'fetch')
|
|
.mockImplementation(async (input) => {
|
|
if (String(input) === '/api/auth/refresh') {
|
|
refreshCalls += 1;
|
|
return json({}, 401);
|
|
}
|
|
return json({}, status);
|
|
});
|
|
await expect(
|
|
requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'),
|
|
).rejects.toMatchObject({ status });
|
|
// 401 刷新先用当前 cookie 收敛重试一次;重试仍被拒绝才算登录态权威失效,
|
|
// 而且不能把一次卡片级失败放大成全局登出。
|
|
expect(refreshCalls).toBe(2);
|
|
expect(fetch).toHaveBeenCalledTimes(3);
|
|
expect(getStoredAuthAccessToken()).toBe('');
|
|
},
|
|
);
|
|
|
|
it('续期 401 后用当前 cookie 收敛重试并继续业务请求', async () => {
|
|
let refreshCalls = 0;
|
|
const fetch = vi
|
|
.spyOn(globalThis, 'fetch')
|
|
.mockImplementation(async (input, init) => {
|
|
if (String(input) === '/api/auth/refresh') {
|
|
refreshCalls += 1;
|
|
return refreshCalls === 1
|
|
? json({}, 401)
|
|
: json({ token: 'rotated-token' });
|
|
}
|
|
if (String(input) === '/api/auth/me') return json({ user });
|
|
const token = new Headers(init?.headers).get('Authorization');
|
|
if (token === 'Bearer expired-token') return json({}, 401);
|
|
expect(token).toBe('Bearer rotated-token');
|
|
return json(catalog);
|
|
});
|
|
|
|
await expect(
|
|
requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'),
|
|
).resolves.toEqual(catalog);
|
|
expect(refreshCalls).toBe(2);
|
|
expect(getStoredAuthAccessToken()).toBe('rotated-token');
|
|
});
|
|
|
|
it('跳过鉴权的请求不触发续期', async () => {
|
|
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 401));
|
|
await expect(
|
|
requestClientApi('/api/example', {}, '读取失败', { skipAuth: true }),
|
|
).rejects.toMatchObject({ status: 401 });
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('403 权限拒绝不触发续期或重发写请求', async () => {
|
|
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 403));
|
|
await expect(
|
|
requestClientApi(
|
|
'/api/example',
|
|
{ method: 'POST', body: '{}' },
|
|
'权限不足',
|
|
),
|
|
).rejects.toMatchObject({ status: 403 });
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
expect(nativeInvoke).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('续期成功后的再次未授权不循环重试', async () => {
|
|
const fetch = vi
|
|
.spyOn(globalThis, 'fetch')
|
|
.mockResolvedValueOnce(json({}, 401))
|
|
.mockResolvedValueOnce(json({ token: 'fresh-token' }))
|
|
.mockResolvedValueOnce(json({ user }))
|
|
.mockResolvedValueOnce(json({}, 401));
|
|
await expect(
|
|
requestClientApi('/api/llm/models', {}, '读取失败'),
|
|
).rejects.toMatchObject({ status: 401 });
|
|
expect(fetch).toHaveBeenCalledTimes(4);
|
|
});
|
|
|
|
it('请求期间账号切换后,不替新账号续期或重发旧请求', async () => {
|
|
let finish!: (response: Response) => void;
|
|
const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(
|
|
() =>
|
|
new Promise<Response>((resolve) => {
|
|
finish = resolve;
|
|
}),
|
|
);
|
|
const pending = requestClientApi('/api/llm/models', {}, '读取失败');
|
|
const rejection = expect(pending).rejects.toMatchObject({ status: 401 });
|
|
const generation = beginPlatformSessionTransition();
|
|
setStoredAuthAccessToken('other-token');
|
|
await commitAuthenticatedPlatformSession(
|
|
{ ...user, id: 'other-user' },
|
|
generation,
|
|
);
|
|
finish(json({}, 401));
|
|
await rejection;
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('响应体卡住超时后,下一次续期会重新发起请求', async () => {
|
|
vi.useFakeTimers();
|
|
let refreshCalls = 0;
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
|
|
if (input === '/api/auth/refresh') {
|
|
refreshCalls += 1;
|
|
}
|
|
return Promise.resolve(
|
|
new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start() {
|
|
// Simulate headers returned while the body remains open.
|
|
},
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
);
|
|
});
|
|
|
|
const first = refreshClientAuthAccessToken('http://localhost:3000');
|
|
const firstAssertion = expect(first).rejects.toThrow();
|
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
await firstAssertion;
|
|
expect(getClientAuthRefreshOperation('http://localhost:3000')).toMatchObject({
|
|
kind: 'auth-refresh',
|
|
phase: 'retryable-failure',
|
|
});
|
|
|
|
const second = refreshClientAuthAccessToken('http://localhost:3000');
|
|
const secondAssertion = expect(second).rejects.toThrow();
|
|
expect(refreshCalls).toBe(2);
|
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
await secondAssertion;
|
|
});
|