Files
makelore/tests/unit/learning-ipc-account-isolation.test.ts
brother7 f7171a471a
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
merge: integrate remote learning module safely
2026-08-17 01:05:49 +08:00

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