422 lines
15 KiB
TypeScript
422 lines
15 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createImagePromptMuseumRouteHandler } from '@electron/api/routes/image-prompt-museum';
|
|
import type { PromptMuseumCard } from '../../shared/image-prompt-museum';
|
|
|
|
function createResponse() {
|
|
const chunks: string[] = [];
|
|
const res = {
|
|
statusCode: 0,
|
|
setHeader: vi.fn(),
|
|
end: vi.fn((chunk?: string) => {
|
|
if (chunk) chunks.push(chunk);
|
|
}),
|
|
} as unknown as ServerResponse;
|
|
return {
|
|
res,
|
|
get json() {
|
|
return JSON.parse(chunks.join('')) as Record<string, unknown>;
|
|
},
|
|
};
|
|
}
|
|
|
|
function card(imageUrl = '/api/image-prompt-museum/prompt-one/media/thumbnail'): PromptMuseumCard {
|
|
return {
|
|
id: 'prompt-one',
|
|
slug: 'prompt-one',
|
|
title: '示例',
|
|
summary: '示例摘要',
|
|
thumbnail: { url: imageUrl, width: 1200, height: 900, alt: '示例' },
|
|
categories: [],
|
|
model: { id: 'image-model', name: 'Image Model' },
|
|
language: 'zh-CN',
|
|
attribution: {
|
|
author: { name: '作者' },
|
|
source: { name: '来源', url: 'https://example.com/source' },
|
|
license: { name: 'CC BY 4.0', attributionText: '作者 / 来源 / CC BY 4.0' },
|
|
},
|
|
publishedAt: '2026-08-01T00:00:00Z',
|
|
updatedAt: '2026-08-01T00:00:00Z',
|
|
};
|
|
}
|
|
|
|
describe('Prompt Museum Main route boundary', () => {
|
|
const fetchImpl = vi.fn<typeof fetch>();
|
|
const getAccessToken = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
fetchImpl.mockReset();
|
|
getAccessToken.mockReset();
|
|
});
|
|
|
|
it('does not claim unrelated routes', async () => {
|
|
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
|
const response = createResponse();
|
|
|
|
await expect(handler(
|
|
{ method: 'GET' } as IncomingMessage,
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/projects'),
|
|
{} as never,
|
|
)).resolves.toBe(false);
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('forwards only the allowed list query and the Works Square token', async () => {
|
|
getAccessToken.mockResolvedValue('works-token');
|
|
fetchImpl.mockResolvedValue(new Response(JSON.stringify({
|
|
success: true,
|
|
data: { items: [], facets: { useCases: [], styles: [], subjects: [] }, nextCursor: null },
|
|
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
|
const handler = createImagePromptMuseumRouteHandler({
|
|
fetchImpl,
|
|
getAccessToken,
|
|
apiBaseUrl: 'https://square.example',
|
|
});
|
|
const response = createResponse();
|
|
|
|
await expect(handler(
|
|
{ method: 'GET' } as IncomingMessage,
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/image-prompt-museum?q=editorial&unknown=do-not-forward&limit=24'),
|
|
{} as never,
|
|
)).resolves.toBe(true);
|
|
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'https://square.example/api/image-prompt-museum?q=editorial&limit=24',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
Authorization: 'Bearer works-token',
|
|
},
|
|
}),
|
|
);
|
|
expect(response.json).toMatchObject({ success: true });
|
|
});
|
|
|
|
it('accepts controlled relative media URLs in list and detail DTOs', async () => {
|
|
getAccessToken.mockResolvedValue('works-token');
|
|
fetchImpl
|
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
success: true,
|
|
data: {
|
|
items: [card()],
|
|
facets: { useCases: [], styles: [], subjects: [] },
|
|
nextCursor: null,
|
|
},
|
|
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
success: true,
|
|
data: {
|
|
...card(),
|
|
prompt: 'Create an image',
|
|
variables: [],
|
|
images: [{ url: '/api/image-prompt-museum/prompt-one/media/0', width: 1200, height: 900, alt: '详情' }],
|
|
requiresReferenceImages: false,
|
|
},
|
|
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
|
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
|
const listResponse = createResponse();
|
|
const detailResponse = createResponse();
|
|
|
|
await handler(
|
|
{ method: 'GET' } as IncomingMessage,
|
|
listResponse.res,
|
|
new URL('http://127.0.0.1/api/works/image-prompt-museum'),
|
|
{} as never,
|
|
);
|
|
await handler(
|
|
{ method: 'GET' } as IncomingMessage,
|
|
detailResponse.res,
|
|
new URL('http://127.0.0.1/api/works/image-prompt-museum/prompt-one'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(listResponse.json).toMatchObject({
|
|
success: true,
|
|
data: { items: [{ thumbnail: { url: '/api/image-prompt-museum/prompt-one/media/thumbnail' } }] },
|
|
});
|
|
expect(detailResponse.json).toMatchObject({
|
|
success: true,
|
|
data: { images: [{ url: '/api/image-prompt-museum/prompt-one/media/0' }] },
|
|
});
|
|
});
|
|
|
|
it('accepts list attribution sources with a missing or null optional URL', async () => {
|
|
getAccessToken.mockResolvedValue('works-token');
|
|
const itemWithoutUrl = card();
|
|
itemWithoutUrl.attribution.source = { name: 'PromptHero' };
|
|
const itemWithNullUrl = card();
|
|
itemWithNullUrl.id = 'prompt-two';
|
|
itemWithNullUrl.attribution.source = { name: 'Community archive', url: null };
|
|
fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
success: true,
|
|
data: {
|
|
items: [itemWithoutUrl, itemWithNullUrl],
|
|
facets: { useCases: [], styles: [], subjects: [] },
|
|
nextCursor: null,
|
|
},
|
|
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
|
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
|
const response = createResponse();
|
|
|
|
await handler(
|
|
{ method: 'GET' } as IncomingMessage,
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/image-prompt-museum'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(response.res.statusCode).toBe(200);
|
|
expect(response.json).toMatchObject({
|
|
success: true,
|
|
data: { items: [
|
|
{ attribution: { source: { name: 'PromptHero' } } },
|
|
{ attribution: { source: { name: 'Community archive', url: null } } },
|
|
] },
|
|
});
|
|
const sources = (response.json.data as { items: Array<{ attribution: { source: unknown } }> })
|
|
.items.map((item) => item.attribution.source);
|
|
expect(sources).toEqual([
|
|
{ name: 'PromptHero' },
|
|
{ name: 'Community archive', url: null },
|
|
]);
|
|
});
|
|
|
|
it('proxies a fixed media path with Bearer auth and returns only bounded raster data', async () => {
|
|
getAccessToken.mockResolvedValue('works-token');
|
|
fetchImpl.mockResolvedValue(new Response(Uint8Array.from([1, 2, 3]), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'image/png; charset=binary', 'Content-Length': '3' },
|
|
}));
|
|
const handler = createImagePromptMuseumRouteHandler({
|
|
fetchImpl,
|
|
getAccessToken,
|
|
apiBaseUrl: 'https://square.example',
|
|
});
|
|
const response = createResponse();
|
|
|
|
await handler(
|
|
{ method: 'GET' } as IncomingMessage,
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/image-prompt-museum/entry-1:en/media/thumbnail'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'https://square.example/api/image-prompt-museum/entry-1:en/media/thumbnail',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ Authorization: 'Bearer works-token' }),
|
|
redirect: 'manual',
|
|
}),
|
|
);
|
|
expect(response.json).toEqual({ dataBase64: 'AQID', mimeType: 'image/png' });
|
|
});
|
|
|
|
it.each([
|
|
['non-image media', { 'Content-Type': 'text/html' }],
|
|
['oversized media', { 'Content-Type': 'image/webp', 'Content-Length': String(10 * 1024 * 1024 + 1) }],
|
|
])('rejects %s responses', async (_name, headers) => {
|
|
getAccessToken.mockResolvedValue('works-token');
|
|
fetchImpl.mockResolvedValue(new Response('unsafe', { status: 200, headers }));
|
|
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
|
const response = createResponse();
|
|
|
|
await handler(
|
|
{ method: 'GET' } as IncomingMessage,
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/image-prompt-museum/prompt-one/media/0'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(response.res.statusCode).toBe(502);
|
|
expect(response.json).toMatchObject({ code: 'PROMPT_MUSEUM_INVALID_RESPONSE' });
|
|
});
|
|
|
|
it('does not claim unsafe or unknown media paths', async () => {
|
|
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
|
const unsafeResponse = createResponse();
|
|
const unknownResponse = createResponse();
|
|
|
|
await expect(handler(
|
|
{ method: 'GET' } as IncomingMessage,
|
|
unsafeResponse.res,
|
|
new URL('http://127.0.0.1/api/works/image-prompt-museum/../media/thumbnail'),
|
|
{} as never,
|
|
)).resolves.toBe(false);
|
|
await expect(handler(
|
|
{ method: 'GET' } as IncomingMessage,
|
|
unknownResponse.res,
|
|
new URL('http://127.0.0.1/api/works/image-prompt-museum/prompt-one/media/original'),
|
|
{} as never,
|
|
)).resolves.toBe(false);
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns a stable auth error without calling the upstream service', async () => {
|
|
getAccessToken.mockResolvedValue(null);
|
|
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
|
const response = createResponse();
|
|
|
|
await handler(
|
|
{ method: 'GET' } as IncomingMessage,
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/image-prompt-museum'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(response.res.statusCode).toBe(401);
|
|
expect(response.json).toMatchObject({
|
|
success: false,
|
|
code: 'PROMPT_MUSEUM_AUTH_REQUIRED',
|
|
});
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refreshes an upstream 401 once and retries with the new Bearer token', async () => {
|
|
getAccessToken
|
|
.mockResolvedValueOnce('expired-token')
|
|
.mockResolvedValueOnce('fresh-token');
|
|
fetchImpl
|
|
.mockResolvedValueOnce(new Response(JSON.stringify({ success: false }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}))
|
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
success: true,
|
|
data: { items: [], facets: { useCases: [], styles: [], subjects: [] }, nextCursor: null },
|
|
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
|
const handler = createImagePromptMuseumRouteHandler({
|
|
fetchImpl,
|
|
getAccessToken,
|
|
apiBaseUrl: 'https://square.example',
|
|
});
|
|
const response = createResponse();
|
|
|
|
await handler(
|
|
{ method: 'GET' } as IncomingMessage,
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/image-prompt-museum'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(getAccessToken).toHaveBeenNthCalledWith(1, { fetchImpl });
|
|
expect(getAccessToken).toHaveBeenNthCalledWith(2, { fetchImpl, forceRefresh: true });
|
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
expect(fetchImpl).toHaveBeenNthCalledWith(
|
|
1,
|
|
'https://square.example/api/image-prompt-museum',
|
|
expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer expired-token' }) }),
|
|
);
|
|
expect(fetchImpl).toHaveBeenNthCalledWith(
|
|
2,
|
|
'https://square.example/api/image-prompt-museum',
|
|
expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer fresh-token' }) }),
|
|
);
|
|
expect(response.res.statusCode).toBe(200);
|
|
expect(response.json).toEqual({
|
|
success: true,
|
|
data: { items: [], facets: { useCases: [], styles: [], subjects: [] }, nextCursor: null },
|
|
});
|
|
});
|
|
|
|
it('returns the stable auth error when an upstream 401 cannot refresh the session', async () => {
|
|
getAccessToken
|
|
.mockResolvedValueOnce('expired-token')
|
|
.mockResolvedValueOnce(null);
|
|
fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
success: false,
|
|
error: 'upstream credential detail',
|
|
}), { status: 401, headers: { 'Content-Type': 'application/json' } }));
|
|
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
|
const response = createResponse();
|
|
|
|
await handler(
|
|
{ method: 'GET' } as IncomingMessage,
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/image-prompt-museum'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(response.res.statusCode).toBe(401);
|
|
expect(response.json).toEqual({
|
|
success: false,
|
|
status: 401,
|
|
code: 'PROMPT_MUSEUM_AUTH_REQUIRED',
|
|
error: '请先登录后再获取灵感',
|
|
});
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('redacts upstream and local failure details', async () => {
|
|
getAccessToken.mockResolvedValue('works-token');
|
|
fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
success: false,
|
|
code: 'INTERNAL_DATABASE_FAILURE',
|
|
error: 'postgres://secret-host/private-table',
|
|
}), { status: 503, headers: { 'Content-Type': 'application/json' } }));
|
|
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
|
const upstreamResponse = createResponse();
|
|
|
|
await handler(
|
|
{ method: 'GET' } as IncomingMessage,
|
|
upstreamResponse.res,
|
|
new URL('http://127.0.0.1/api/works/image-prompt-museum'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(upstreamResponse.json).toEqual({
|
|
success: false,
|
|
status: 503,
|
|
code: 'PROMPT_MUSEUM_UNAVAILABLE',
|
|
error: '提示词博物馆暂时不可用',
|
|
});
|
|
|
|
fetchImpl.mockRejectedValueOnce(new Error('C:\\private\\network.log'));
|
|
const localResponse = createResponse();
|
|
await handler(
|
|
{ method: 'GET' } as IncomingMessage,
|
|
localResponse.res,
|
|
new URL('http://127.0.0.1/api/works/image-prompt-museum'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(localResponse.json).toEqual({
|
|
success: false,
|
|
status: 502,
|
|
code: 'PROMPT_MUSEUM_UNAVAILABLE',
|
|
error: '提示词博物馆暂时不可用',
|
|
});
|
|
});
|
|
|
|
it('rejects malformed success DTOs and unsafe image URLs', async () => {
|
|
getAccessToken.mockResolvedValue('works-token');
|
|
fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
success: true,
|
|
data: {
|
|
items: [card('http://internal.example/secret.png')],
|
|
facets: { useCases: [], styles: [], subjects: [] },
|
|
nextCursor: null,
|
|
},
|
|
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
|
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
|
const response = createResponse();
|
|
|
|
await handler(
|
|
{ method: 'GET' } as IncomingMessage,
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/image-prompt-museum'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(response.res.statusCode).toBe(502);
|
|
expect(response.json).toEqual({
|
|
success: false,
|
|
status: 502,
|
|
code: 'PROMPT_MUSEUM_INVALID_RESPONSE',
|
|
error: '提示词博物馆返回了无效数据',
|
|
});
|
|
});
|
|
});
|