// @vitest-environment node import { beforeEach, describe, expect, it, vi } from 'vitest'; const state = vi.hoisted(() => ({ handlers: new Map unknown>(), binding: { accountKey: 'a'.repeat(64), epoch: 1 } as { accountKey: string; epoch: number } | null, sessionListener: null as null | (() => void), agentClients: [] as Array<{ ask: ReturnType; reset: ReturnType }>, 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((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); }); });