239 lines
11 KiB
TypeScript
239 lines
11 KiB
TypeScript
// @vitest-environment node
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const state = vi.hoisted(() => ({
|
|
handlers: new Map<string, (...args: unknown[]) => unknown>(),
|
|
binding: { accountKey: 'a'.repeat(64), epoch: 1 } as { accountKey: string; epoch: number } | null,
|
|
sessionListener: null as null | (() => void),
|
|
agentClients: [] as Array<{ ask: ReturnType<typeof vi.fn>; reset: ReturnType<typeof vi.fn> }>,
|
|
runtimeRequest: vi.fn(),
|
|
evict: vi.fn(),
|
|
closePlayer: vi.fn(async () => undefined),
|
|
assertRegistered: vi.fn(),
|
|
readClassroom: vi.fn(),
|
|
resolveClassroom: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('electron', () => ({
|
|
app: { getPath: vi.fn(() => 'C:\\user-data'), getVersion: vi.fn(), getName: vi.fn(), quit: vi.fn(), relaunch: vi.fn(), exit: vi.fn() },
|
|
BrowserWindow: class {},
|
|
dialog: {},
|
|
ipcMain: { handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => state.handlers.set(channel, handler)) },
|
|
shell: {},
|
|
}));
|
|
|
|
vi.mock('@electron/main/ipc/host-api-proxy', () => ({ registerHostApiProxyHandlers: vi.fn() }));
|
|
vi.mock('@electron/main/ipc/transcript-export', () => ({ registerTranscriptExportHandler: vi.fn() }));
|
|
vi.mock('@electron/services/learning-course-library', () => ({
|
|
LearningCourseLibraryError: class LearningCourseLibraryError extends Error {
|
|
constructor(readonly code: string, message: string) { super(message); }
|
|
},
|
|
createLearningCourseLibrary: vi.fn(() => ({
|
|
download: vi.fn(),
|
|
listInstalled: vi.fn(),
|
|
readClassroom: state.readClassroom,
|
|
resolveClassroom: state.resolveClassroom,
|
|
})),
|
|
}));
|
|
vi.mock('@electron/services/learning-player-server', () => ({
|
|
assertLearningCoursePackageRegistered: (...args: unknown[]) => state.assertRegistered(...args),
|
|
closeLearningPlayerServer: () => state.closePlayer(),
|
|
evictLearningCoursePackagesForAccount: (...args: unknown[]) => state.evict(...args),
|
|
getLearningPlayerServer: vi.fn(async () => ({ url: 'http://127.0.0.1/player' })),
|
|
}));
|
|
vi.mock('@electron/services/works-square-session', () => ({
|
|
getWorksSquareAccountBinding: () => state.binding,
|
|
isCurrentWorksSquareAccountBinding: (binding: { accountKey: string; epoch: number }) => (
|
|
binding.accountKey === state.binding?.accountKey && binding.epoch === state.binding.epoch
|
|
),
|
|
subscribeWorksSquareSession: (listener: () => void) => { state.sessionListener = listener; return vi.fn(); },
|
|
}));
|
|
vi.mock('@electron/services/learning-agent-client', () => ({
|
|
createLearningAgentClient: vi.fn(() => {
|
|
const client = { ask: vi.fn(), reset: vi.fn() };
|
|
state.agentClients.push(client);
|
|
return client;
|
|
}),
|
|
}));
|
|
vi.mock('@electron/services/learning-speech-client', () => ({ createLearningSpeechClient: vi.fn(() => ({ transcribe: vi.fn() })) }));
|
|
vi.mock('@electron/services/learning-generation-client', () => ({ createLearningGenerationClient: vi.fn(() => ({ start: vi.fn() })) }));
|
|
vi.mock('@electron/services/learning-runtime-bridge', () => ({
|
|
createLearningRuntimeBridge: vi.fn(() => ({ request: (...args: unknown[]) => state.runtimeRequest(...args) })),
|
|
}));
|
|
|
|
import { registerIpcHandlers } from '@electron/main/ipc-handlers';
|
|
|
|
describe('Learning IPC account isolation', () => {
|
|
beforeEach(() => {
|
|
state.handlers.clear();
|
|
state.binding = { accountKey: 'a'.repeat(64), epoch: 1 };
|
|
state.sessionListener = null;
|
|
state.agentClients.length = 0;
|
|
state.runtimeRequest.mockReset();
|
|
state.evict.mockReset();
|
|
state.closePlayer.mockClear();
|
|
state.assertRegistered.mockReset();
|
|
const classroom = {
|
|
courseId: 'course-1',
|
|
courseContentHash: 'a'.repeat(64),
|
|
contentHash: 'a'.repeat(64),
|
|
moduleId: null,
|
|
moduleContentHash: 'a'.repeat(64),
|
|
modules: [],
|
|
classroom: { stage: {}, scenes: [] },
|
|
};
|
|
state.readClassroom.mockReset().mockResolvedValue(classroom);
|
|
state.resolveClassroom.mockReset().mockResolvedValue(classroom);
|
|
registerIpcHandlers(null, {} as never, null, {} as never);
|
|
});
|
|
|
|
it('rejects an old in-flight Agent result and recreates the client after account switch', async () => {
|
|
let resolveOld!: (value: { text: string }) => void;
|
|
state.agentClients[0].ask.mockImplementationOnce(() => new Promise((resolve) => { resolveOld = resolve; }));
|
|
const handler = state.handlers.get('learning:agentAsk')!;
|
|
const oldResult = handler({}, { courseId: 'course-1', contentHash: 'a'.repeat(64), message: 'old' });
|
|
await vi.waitFor(() => expect(state.agentClients[0].ask).toHaveBeenCalledOnce());
|
|
|
|
state.binding = { accountKey: 'b'.repeat(64), epoch: 2 };
|
|
state.sessionListener!();
|
|
resolveOld({ text: 'private A answer' });
|
|
|
|
await expect(oldResult).rejects.toMatchObject({ code: 'LEARNING_ACCOUNT_CHANGED' });
|
|
expect(state.evict).toHaveBeenCalledWith('a'.repeat(64));
|
|
expect(state.closePlayer).toHaveBeenCalledOnce();
|
|
expect(state.agentClients).toHaveLength(2);
|
|
state.agentClients[1].ask.mockResolvedValueOnce({ text: 'B answer' });
|
|
await expect(handler({}, { courseId: 'course-1', contentHash: 'a'.repeat(64), message: 'new' }))
|
|
.resolves.toEqual({ text: 'B answer' });
|
|
});
|
|
|
|
it('drops runtime events emitted after the bound account changes', async () => {
|
|
let emit!: (payload: unknown) => void;
|
|
let resolveRequest!: () => void;
|
|
state.runtimeRequest.mockImplementationOnce((_request, listener) => {
|
|
emit = listener;
|
|
return new Promise<void>((resolve) => { resolveRequest = resolve; });
|
|
});
|
|
const sender = { isDestroyed: vi.fn(() => false), send: vi.fn() };
|
|
const result = state.handlers.get('learning:runtimeRequest')!({ sender }, {
|
|
requestId: 'request-1',
|
|
courseId: 'course-1',
|
|
contentHash: 'a'.repeat(64),
|
|
capability: 'quiz-grade',
|
|
method: 'POST',
|
|
context: { moduleId: null, moduleContentHash: 'a'.repeat(64), anchor: { sceneId: 'scene-1' } },
|
|
body: {},
|
|
});
|
|
await vi.waitFor(() => expect(state.runtimeRequest).toHaveBeenCalledOnce());
|
|
emit({ type: 'chunk' });
|
|
expect(sender.send).toHaveBeenCalledOnce();
|
|
|
|
state.binding = { accountKey: 'b'.repeat(64), epoch: 2 };
|
|
state.sessionListener!();
|
|
emit({ type: 'chunk' });
|
|
expect(sender.send).toHaveBeenCalledOnce();
|
|
resolveRequest();
|
|
await expect(result).rejects.toMatchObject({ code: 'LEARNING_ACCOUNT_CHANGED' });
|
|
});
|
|
|
|
it('rejects forged aggregate and module identities before Agent or runtime network calls', async () => {
|
|
state.resolveClassroom.mockResolvedValue({
|
|
courseId: 'course-1',
|
|
courseContentHash: 'a'.repeat(64),
|
|
contentHash: 'a'.repeat(64),
|
|
moduleId: 'module-1',
|
|
moduleContentHash: 'b'.repeat(64),
|
|
modules: [],
|
|
classroom: { stage: {}, scenes: [] },
|
|
});
|
|
await expect(state.handlers.get('learning:agentAsk')!({}, {
|
|
courseId: 'course-1',
|
|
contentHash: 'c'.repeat(64),
|
|
message: '伪造课程',
|
|
anchor: { moduleId: 'module-1', moduleContentHash: 'b'.repeat(64) },
|
|
})).rejects.toMatchObject({ code: 'LEARNING_COURSE_IDENTITY_INVALID' });
|
|
expect(state.agentClients[0].ask).not.toHaveBeenCalled();
|
|
|
|
const sender = { isDestroyed: vi.fn(() => false), send: vi.fn() };
|
|
await expect(state.handlers.get('learning:runtimeRequest')!({ sender }, {
|
|
requestId: 'request-2', courseId: 'course-1', contentHash: 'a'.repeat(64), capability: 'quiz-grade', method: 'POST',
|
|
context: { moduleId: 'module-1', moduleContentHash: 'c'.repeat(64), anchor: { sceneId: 'scene-1' } }, body: {},
|
|
})).rejects.toMatchObject({ code: 'LEARNING_COURSE_IDENTITY_INVALID' });
|
|
expect(state.runtimeRequest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects an installed classroom that is not registered for the current player session', async () => {
|
|
state.assertRegistered.mockImplementationOnce(() => { throw new Error('not registered'); });
|
|
await expect(state.handlers.get('learning:agentAsk')!({}, {
|
|
courseId: 'course-1', contentHash: 'a'.repeat(64), message: '你好',
|
|
})).rejects.toMatchObject({ code: 'LEARNING_COURSE_IDENTITY_INVALID' });
|
|
expect(state.agentClients[0].ask).not.toHaveBeenCalled();
|
|
expect(state.resolveClassroom).not.toHaveBeenCalled();
|
|
expect(state.readClassroom).not.toHaveBeenCalled();
|
|
|
|
state.assertRegistered.mockImplementationOnce(() => { throw new Error('not registered'); });
|
|
const sender = { isDestroyed: vi.fn(() => false), send: vi.fn() };
|
|
await expect(state.handlers.get('learning:runtimeRequest')!({ sender }, {
|
|
requestId: 'request-unregistered', courseId: 'course-1', contentHash: 'a'.repeat(64), capability: 'quiz-grade', method: 'POST',
|
|
context: { moduleId: null, moduleContentHash: 'a'.repeat(64), anchor: { sceneId: 'scene-1' } }, body: {},
|
|
})).rejects.toMatchObject({ code: 'LEARNING_COURSE_IDENTITY_INVALID' });
|
|
expect(state.runtimeRequest).not.toHaveBeenCalled();
|
|
expect(state.resolveClassroom).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('maps malformed outer course identities to a fixed error before resolve or network access', async () => {
|
|
const fixedIdentityError = {
|
|
code: 'LEARNING_COURSE_IDENTITY_INVALID',
|
|
message: '课程身份无效',
|
|
};
|
|
const agentHandler = state.handlers.get('learning:agentAsk')!;
|
|
for (const request of [
|
|
null,
|
|
{ courseId: '../course', contentHash: 'a'.repeat(64), message: '你好' },
|
|
{ courseId: 'course-1', contentHash: 'bad', message: '你好' },
|
|
{ courseId: 'course-1', contentHash: 'a'.repeat(64), message: '你好', anchor: null },
|
|
{
|
|
courseId: 'course-1', contentHash: 'a'.repeat(64), message: '你好',
|
|
anchor: { moduleId: '..', moduleContentHash: 'b'.repeat(64) },
|
|
},
|
|
]) {
|
|
await expect(agentHandler({}, request)).rejects.toMatchObject(fixedIdentityError);
|
|
}
|
|
|
|
const runtimeHandler = state.handlers.get('learning:runtimeRequest')!;
|
|
const sender = { isDestroyed: vi.fn(() => false), send: vi.fn() };
|
|
for (const request of [
|
|
null,
|
|
{ courseId: 'course-1', contentHash: 'a'.repeat(64), context: null },
|
|
{
|
|
courseId: 'course-1', contentHash: 'a'.repeat(64),
|
|
context: { moduleId: '..', moduleContentHash: 'b'.repeat(64) },
|
|
},
|
|
{
|
|
courseId: 'course-1', contentHash: 'a'.repeat(64),
|
|
context: { moduleId: null, moduleContentHash: 'bad' },
|
|
},
|
|
]) {
|
|
await expect(runtimeHandler({ sender }, request)).rejects.toMatchObject(fixedIdentityError);
|
|
}
|
|
|
|
const resetHandler = state.handlers.get('learning:agentReset')!;
|
|
await expect(resetHandler({}, '../course', 'a'.repeat(64))).rejects.toMatchObject(fixedIdentityError);
|
|
await expect(resetHandler({}, 'course-1', 'bad')).rejects.toMatchObject(fixedIdentityError);
|
|
await expect(resetHandler({}, undefined, 'a'.repeat(64))).rejects.toMatchObject(fixedIdentityError);
|
|
|
|
expect(state.assertRegistered).not.toHaveBeenCalled();
|
|
expect(state.resolveClassroom).not.toHaveBeenCalled();
|
|
expect(state.agentClients[0].ask).not.toHaveBeenCalled();
|
|
expect(state.runtimeRequest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('closes the player and rotates the Agent client when only the epoch changes', () => {
|
|
state.binding = { accountKey: 'a'.repeat(64), epoch: 2 };
|
|
state.sessionListener!();
|
|
expect(state.evict).toHaveBeenCalledWith('a'.repeat(64));
|
|
expect(state.closePlayer).toHaveBeenCalledOnce();
|
|
expect(state.agentClients).toHaveLength(2);
|
|
});
|
|
});
|