merge: integrate remote learning module safely
This commit is contained in:
commit
f7171a471a
125 files changed
+11451
-12132
No files matched your search
@@ -0,0 +1,238 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user