feat(agents): 复用 Yuxi 头像上传与展示
This commit is contained in:
1 parent
0ca744fb9b
commit
235af24d6c
16 files changed
+381
-32
No files matched your search
@@ -19,6 +19,7 @@ vi.mock('electron', () => { const electron = {
|
||||
|
||||
const draft = {
|
||||
slug: 'ml-' + 'a'.repeat(32), name: '写作搭档', purpose: '帮助写作', system_prompt: '简明表达',
|
||||
icon: null, icon_url: null,
|
||||
draft_revision: 1, updated_at: '2026-09-10T00:00:00Z',
|
||||
configuration: EMPTY_CLOUD_CONFIGURATION, published_version: null, enabled: true, archived: false,
|
||||
};
|
||||
@@ -44,6 +45,73 @@ beforeEach(() => { vi.clearAllMocks(); journalRecords.clear(); clearWorksSquareS
|
||||
afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); });
|
||||
|
||||
describe('Main cloud Agents boundary', () => {
|
||||
it('uploads avatar bytes through Main and resolves Yuxi public URLs without exposing credentials', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'makelore-avatar-'));
|
||||
try {
|
||||
const source = join(directory, 'avatar.png');
|
||||
const bytes = Buffer.from('avatar bytes validated by Yuxi');
|
||||
await writeFile(source, bytes);
|
||||
desktop.pick.mockResolvedValue({ canceled: false, filePaths: [source] });
|
||||
const icon = '/api/storage/public/images/creator/avatar.png';
|
||||
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ icon, private: 'hidden' }));
|
||||
expect(await moduleFor(transport).uploadAvatar()).toEqual({ icon, icon_url: 'https://agents.example.test' + icon });
|
||||
expect(desktop.pick.mock.calls[0][0].filters[0].extensions).toContain('png');
|
||||
expect(transport.mock.calls[1][0]).toBe('https://agents.example.test/api/makelore/avatars');
|
||||
const body = transport.mock.calls[1][1].body as FormData;
|
||||
expect(Buffer.from(await (body.get('file') as File).arrayBuffer())).toEqual(bytes);
|
||||
} finally { await rm(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it('cancels avatar selection without a request and rejects selection from a previous account', async () => {
|
||||
const transport = vi.fn();
|
||||
desktop.pick.mockResolvedValueOnce({ canceled: true, filePaths: [] });
|
||||
const module = moduleFor(transport);
|
||||
expect(await module.uploadAvatar()).toBeNull();
|
||||
desktop.pick.mockImplementationOnce(async () => { login('b'.repeat(64)); return { canceled: false, filePaths: ['old.png'] }; });
|
||||
await expect(module.uploadAvatar()).rejects.toMatchObject({ code: 'account_changed' });
|
||||
expect(transport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects oversized avatars before reading or transmitting them', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'makelore-avatar-limit-'));
|
||||
try {
|
||||
const source = join(directory, 'large.png');
|
||||
await writeFile(source, Buffer.alloc(5 * 1024 * 1024 + 1));
|
||||
desktop.pick.mockResolvedValue({ canceled: false, filePaths: [source] });
|
||||
const transport = vi.fn();
|
||||
await expect(moduleFor(transport).uploadAvatar()).rejects.toMatchObject({ code: 'avatar_too_large' });
|
||||
expect(transport).not.toHaveBeenCalled();
|
||||
} finally { await rm(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it('preserves canonical avatar paths through create/save and projects published and received avatars', async () => {
|
||||
const icon = '/api/storage/public/images/creator/avatar.png';
|
||||
const avatar = { icon, icon_url: 'https://agents.example.test' + icon };
|
||||
const transport = vi.fn(async (url: string) => {
|
||||
if (url.endsWith('/session')) return session();
|
||||
if (url.includes('/received')) return json({ agents: [{ ...draft, icon, private: 'hidden' }], next_offset: null });
|
||||
return json({ ...draft, icon, private: 'hidden' });
|
||||
});
|
||||
const module = moduleFor(transport);
|
||||
expect(await module.create({ ...input, ...avatar })).toMatchObject(avatar);
|
||||
expect(JSON.parse(transport.mock.calls[1][1].body as string)).toEqual({ ...input, icon });
|
||||
await module.save(draft.slug, { ...draft, ...avatar, expected_revision: 1 });
|
||||
expect(JSON.parse(transport.mock.calls[2][1].body as string)).toMatchObject({ icon });
|
||||
expect(transport.mock.calls[2][1].body).not.toContain('icon_url');
|
||||
await module.save(draft.slug, { ...draft, expected_revision: 1, icon: null });
|
||||
expect(JSON.parse(transport.mock.calls[3][1].body as string).icon).toBeNull();
|
||||
const entry = await module.execute({ operation: 'entry', input: { slug: draft.slug } });
|
||||
expect(entry).toMatchObject(avatar); expect(entry).not.toHaveProperty('private');
|
||||
expect(await module.execute({ operation: 'received', input: {} })).toMatchObject({ agents: [avatar] });
|
||||
expect(await module.execute({ operation: 'version', input: { slug: draft.slug, version: '1' } })).toMatchObject(avatar);
|
||||
});
|
||||
|
||||
it.each(['file:///private/avatar.png', 'javascript:alert(1)', 'https://secret@images.example.test/avatar.png'])
|
||||
('falls back for an unusable legacy avatar URL %s', async icon => {
|
||||
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ ...draft, icon }));
|
||||
expect(await moduleFor(transport).get(draft.slug)).toMatchObject({ icon, icon_url: null });
|
||||
});
|
||||
|
||||
it('accepts a five-minute session from a clock one second ahead and caps local caching', async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = Math.floor(Date.now() / 1000) * 1000;
|
||||
|
||||
Reference in new issue
Block a user