212 lines
12 KiB
TypeScript
212 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { mkdtemp, readFile, writeFile, rm, readdir } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { CloudAgentsModule } from '@electron/services/cloud-agents';
|
|
import { EMPTY_CLOUD_CONFIGURATION } from '../../shared/cloud-agents';
|
|
import { clearWorksSquareSession, storeWorksSquareSession } from '@electron/services/works-square-session';
|
|
|
|
const desktop = vi.hoisted(() => ({ pick: vi.fn(), save: vi.fn(), show: vi.fn(), click: vi.fn() }));
|
|
vi.mock('electron', () => ({
|
|
app: { getPath: () => '/tmp/cloud-agents-test', getVersion: () => 'test', isPackaged: false },
|
|
dialog: { showOpenDialog: desktop.pick, showSaveDialog: desktop.save },
|
|
Notification: class { static isSupported() { return true; } on = desktop.click; show = desktop.show; },
|
|
BrowserWindow: { getAllWindows: () => [] },
|
|
}));
|
|
|
|
const draft = {
|
|
slug: 'ml-' + 'a'.repeat(32), name: '写作搭档', purpose: '帮助写作', system_prompt: '简明表达',
|
|
draft_revision: 1, updated_at: '2026-09-10T00:00:00Z',
|
|
configuration: EMPTY_CLOUD_CONFIGURATION, published_version: null, enabled: true,
|
|
};
|
|
const input = { operation_id: '12345678-1234-1234-1234-123456789012', name: draft.name, purpose: draft.purpose };
|
|
const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status });
|
|
const session = () => json({
|
|
access_token: 'yuxi-secret', token_type: 'bearer', scope: 'makelore-agents',
|
|
expires_at: Math.floor(Date.now() / 1000) + 120, api_base_url: 'https://agents.example.test',
|
|
});
|
|
function login(key = 'a'.repeat(64)) {
|
|
storeWorksSquareSession({ accessToken: 'ws-secret', expiresAt: Date.now() + 600000, accountPartitionKey: key });
|
|
}
|
|
const instances: CloudAgentsModule[] = [];
|
|
function moduleFor(fetchImpl: typeof fetch) {
|
|
const module = new CloudAgentsModule(fetchImpl); instances.push(module); return module;
|
|
}
|
|
beforeEach(() => { vi.clearAllMocks(); clearWorksSquareSession(); login(); });
|
|
afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); });
|
|
|
|
describe('Main cloud Agents boundary', () => {
|
|
it('reads upload bytes and writes downloaded bytes only through Main dialogs', async () => {
|
|
const directory = await mkdtemp(join(tmpdir(), 'makelore-cloud-files-'));
|
|
try {
|
|
const source = join(directory, 'material.txt'), destination = join(directory, 'result.txt');
|
|
await writeFile(source, 'local material');
|
|
desktop.pick.mockResolvedValue({ canceled: false, filePaths: [source] });
|
|
desktop.save.mockResolvedValue({ canceled: false, filePath: destination });
|
|
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({
|
|
object_name: 'private-upload', file_name: 'material.txt', parse_supported: false, parse_methods: [],
|
|
})).mockResolvedValueOnce(new Response('cloud result'));
|
|
const module = moduleFor(transport);
|
|
expect((await module.upload())?.file_name).toBe('material.txt');
|
|
const payload = transport.mock.calls[1][1].body as FormData;
|
|
expect(await (payload.get('file') as File).text()).toBe('local material');
|
|
expect(await module.download({ thread_id: 'thread', path: '/mnt/user-data/projects/one/result.txt' })).toEqual({ saved: true });
|
|
expect(await readFile(destination, 'utf8')).toBe('cloud result');
|
|
expect((await readdir(directory)).sort()).toEqual(['material.txt', 'result.txt']);
|
|
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
it('rejects an account switch while a file picker is open without reading or uploading it', async () => {
|
|
let finish!: (value: unknown) => void;
|
|
desktop.pick.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
|
const transport = vi.fn();
|
|
const upload = moduleFor(transport).upload();
|
|
const failure = expect(upload).rejects.toMatchObject({ code: 'account_changed' });
|
|
await vi.waitFor(() => expect(desktop.pick).toHaveBeenCalled());
|
|
login('b'.repeat(64));
|
|
finish({ canceled: false, filePaths: ['not-read.txt'] });
|
|
await failure;
|
|
expect(transport).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('notifies only a new unread terminal state and resets its baseline on account switch', async () => {
|
|
vi.useFakeTimers();
|
|
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ agents: [], next_cursor: null }))
|
|
.mockResolvedValueOnce(json({ threads: [{ run_id: 'one', status: 'running', unread: true }] }))
|
|
.mockResolvedValue(json({ threads: [{ run_id: 'one', status: 'completed', unread: true }] }));
|
|
const module = moduleFor(transport);
|
|
await module.list();
|
|
await vi.advanceTimersByTimeAsync(30000);
|
|
expect(desktop.show).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(30000);
|
|
expect(desktop.show).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(30000);
|
|
expect(desktop.show).toHaveBeenCalledTimes(1);
|
|
login('b'.repeat(64));
|
|
const calls = transport.mock.calls.length;
|
|
await vi.advanceTimersByTimeAsync(30000);
|
|
expect(transport).toHaveBeenCalledTimes(calls);
|
|
});
|
|
|
|
it('allowlists execution fields and preserves safe publication metadata', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({
|
|
request_id: 'request', thread_id: 'thread', run_id: 'run', status: 'pending', version: '4',
|
|
access_token: 'never-render', input_payload: { creator_id: 'private' },
|
|
}));
|
|
const module = moduleFor(fetchImpl);
|
|
const result = await module.execute({ operation: 'submit', input: {
|
|
slug: draft.slug, request_id: 'request', thread_id: 'thread', query: 'hello',
|
|
creator_id: 'forged', payer: 'caller', model: 'unauthorized', snapshot: {},
|
|
} });
|
|
expect(result).toEqual({ request_id: 'request', thread_id: 'thread', run_id: 'run', status: 'pending', version: '4' });
|
|
expect(JSON.parse(fetchImpl.mock.calls[1][1].body)).toEqual({ request_id: 'request', thread_id: 'thread', query: 'hello' });
|
|
});
|
|
|
|
it('uses the WS credential directly for the creator ledger and never exchanges it into Renderer data', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(json({ unit: '词元点数', items: [], access_token: 'private' }));
|
|
const result = await moduleFor(fetchImpl).execute({ operation: 'costs', input: { slug: draft.slug } });
|
|
expect(result).toEqual({ unit: '词元点数', items: [] });
|
|
expect(fetchImpl.mock.calls[0][0]).toContain('/api/cloud-agents/costs?agent_slug=');
|
|
expect(fetchImpl.mock.calls[0][1].headers.Authorization).toBe('Bearer ws-secret');
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('rejects arbitrary operation names and route traversal before any request', async () => {
|
|
const fetchImpl = vi.fn();
|
|
const module = moduleFor(fetchImpl);
|
|
await expect(module.execute({ operation: 'proxy', input: { url: 'https://evil.test' } })).rejects.toMatchObject({ status: 422 });
|
|
await expect(module.execute({ operation: 'run', input: { run_id: '../../admin' } })).rejects.toMatchObject({ status: 422 });
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('passes user validation failures as 422 without returning upstream text', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ detail: [{ msg: 'raw credential' }] }, 422));
|
|
await expect(moduleFor(fetchImpl).execute({ operation: 'createSchedule', input: { slug: draft.slug } }))
|
|
.rejects.toMatchObject({ status: 422, code: 'invalid_input' });
|
|
});
|
|
|
|
it('cancels the active cloud stream immediately on account switch', async () => {
|
|
let streamController!: ReadableStreamDefaultController<Uint8Array>;
|
|
let transportSignal: AbortSignal | undefined;
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockImplementationOnce((_url, init) => {
|
|
transportSignal = init.signal;
|
|
return Promise.resolve(new Response(new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
streamController = controller;
|
|
init.signal.addEventListener('abort', () => controller.error(new Error('aborted')));
|
|
},
|
|
}), { headers: { 'Content-Type': 'text/event-stream' } }));
|
|
});
|
|
const iterator = moduleFor(fetchImpl).events('run-id', '0-0', new AbortController().signal);
|
|
const pending = iterator.next();
|
|
const failed = expect(pending).rejects.toBeDefined();
|
|
await vi.waitFor(() => expect(streamController).toBeDefined());
|
|
login('b'.repeat(64));
|
|
expect(transportSignal?.aborted).toBe(true);
|
|
await failed;
|
|
});
|
|
it('keeps credentials in Main, projects the DTO and sends only creator-independent inputs', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(session())
|
|
.mockResolvedValueOnce(json({ ...draft, access_token: 'do-not-project', owner: 'private' }))
|
|
.mockResolvedValueOnce(json({ agents: [draft], next_cursor: null }));
|
|
const module = moduleFor(fetchImpl);
|
|
expect(await module.create({ ...input, payer: 'other', account_id: 'other' })).toEqual(draft);
|
|
expect(await module.list()).toEqual({ agents: [draft], next_cursor: null });
|
|
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
|
expect(fetchImpl.mock.calls[0][1].headers.Authorization).toBe('Bearer ws-secret');
|
|
expect(fetchImpl.mock.calls[1][1].headers.Authorization).toBe('Bearer yuxi-secret');
|
|
expect(JSON.parse(fetchImpl.mock.calls[1][1].body)).toEqual(input);
|
|
expect(fetchImpl.mock.calls[1][1].redirect).toBe('error');
|
|
});
|
|
|
|
it('rejects the old account response after switching accounts', async () => {
|
|
let finish!: (response: Response) => void;
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockImplementationOnce(() => new Promise<Response>((resolve) => { finish = resolve; }));
|
|
const result = moduleFor(fetchImpl).list();
|
|
const rejection = expect(result).rejects.toMatchObject({ code: 'account_changed' });
|
|
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
|
|
login('b'.repeat(64));
|
|
finish(json({ agents: [draft], next_cursor: null }));
|
|
await rejection;
|
|
});
|
|
|
|
it('does not automatically repeat a possibly committed create and preserves an explicit retry operation', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response ws-secret'))
|
|
.mockResolvedValueOnce(json(draft));
|
|
const module = moduleFor(fetchImpl);
|
|
await expect(module.create(input)).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
|
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
expect(await module.create(input)).toEqual(draft);
|
|
expect(fetchImpl.mock.calls[2][1].body).toBe(fetchImpl.mock.calls[1][1].body);
|
|
});
|
|
|
|
it('passes revision conflicts without exposing upstream diagnostics', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({
|
|
detail: { code: 'draft_revision_conflict', message: 'raw yuxi-secret' },
|
|
}, 409));
|
|
await expect(moduleFor(fetchImpl).save(draft.slug, {
|
|
expected_revision: 1, name: draft.name, purpose: draft.purpose, system_prompt: 'new',
|
|
})).rejects.toMatchObject({ status: 409, code: 'draft_revision_conflict', message: '草稿已在其他设备更新,你的输入已保留' });
|
|
});
|
|
|
|
it('rejects malformed draft responses as service failures', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ ...draft, draft_revision: 'oops' }));
|
|
await expect(moduleFor(fetchImpl).get(draft.slug)).rejects.toMatchObject({ status: 502 });
|
|
});
|
|
|
|
it('rejects missing login before any network request', async () => {
|
|
clearWorksSquareSession();
|
|
const fetchImpl = vi.fn();
|
|
await expect(moduleFor(fetchImpl).list()).rejects.toMatchObject({ status: 401 });
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('times out a stalled response body and projects a safe error', async () => {
|
|
vi.useFakeTimers();
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce({ text: () => new Promise(() => {}) });
|
|
const result = expect(moduleFor(fetchImpl).list()).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
|
|
await vi.advanceTimersByTimeAsync(30000);
|
|
await result;
|
|
});
|
|
});
|