Files
kdletters 4182979a19 平台会话身份与凭据分离,续期不再中断在途生成
- 原生会话快照拆成身份代次与写入 revision,安装与清除按 revision 排序、按身份归属判定
- 冻结平台会话校验改为只比身份,同账号 access token 轮换不再让在途生成、编辑、上传、确认和下载失败
- 同一身份代次禁止更换登录主体或服务 origin,换号、退出和 origin 变化继续失败关闭
- Runner attach 新增 platform_auth_revision,平台会话安装与校验按 revision 排序、按身份归属判定
- GUI owner 替换与清除改为计数器只增不减,同一主体重装保持身份代次,避免旧 epoch 迟到写入复活
- renderer 拆分平台原生身份代次与写入 revision,同账号续期只更新凭据,不推进身份代次
- 刷新失败结果新增权威失效判定,只有服务端明确 401 与 403 才清除本地会话,网络错误和 5xx 保留会话
- 收敛刷新判据,重复的刷新工具函数合并为一个对外判定,去掉一次性包装
- 补齐平台会话身份判据、续期与迟到写入的 Rust 与前端回归用例
2026-09-16 17:08:46 +08:00

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;
});