补强静态发布安全边界
This commit is contained in:
@@ -5,6 +5,11 @@ import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { handleWorksRoutes } from '@electron/api/routes/works';
|
||||
import {
|
||||
getRendererCapability,
|
||||
RENDERER_CAPABILITY_HEADER,
|
||||
rotateRendererCapability,
|
||||
} from '@electron/api/renderer-capability';
|
||||
import { createProjectConfig } from '../../shared/project-config';
|
||||
|
||||
const getValidWorksSquareAccessTokenMock = vi.hoisted(() => vi.fn());
|
||||
@@ -46,6 +51,12 @@ function createRequest(method: string, body?: unknown, headers: Record<string, s
|
||||
return req as IncomingMessage;
|
||||
}
|
||||
|
||||
function createRendererRequest(method: string, body?: unknown): IncomingMessage {
|
||||
return createRequest(method, body, {
|
||||
[RENDERER_CAPABILITY_HEADER]: getRendererCapability(),
|
||||
});
|
||||
}
|
||||
|
||||
async function writePublishableProject(projectPath: string): Promise<void> {
|
||||
await mkdir(join(projectPath, 'src'), { recursive: true });
|
||||
await mkdir(join(projectPath, '.niancode'), { recursive: true });
|
||||
@@ -77,6 +88,7 @@ describe('works square host api routes', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
rotateRendererCapability();
|
||||
getValidWorksSquareAccessTokenMock.mockReset();
|
||||
getValidWorksSquareAccessTokenMock.mockResolvedValue('main-owned-access-token');
|
||||
});
|
||||
@@ -101,6 +113,11 @@ describe('works square host api routes', () => {
|
||||
age_band: '8-12',
|
||||
difficulty: 'beginner',
|
||||
updated_at: '2026-06-20T22:55:37.790408+08:00',
|
||||
playable: true,
|
||||
version_name: 'v1.0.0',
|
||||
play_url: '/apps/space-cleaner/',
|
||||
runtime_url: '/apps/legacy-space-cleaner/',
|
||||
owner_email: 'private@example.com',
|
||||
},
|
||||
],
|
||||
next_cursor: null,
|
||||
@@ -132,18 +149,103 @@ describe('works square host api routes', () => {
|
||||
age_band: '8-12',
|
||||
difficulty: 'beginner',
|
||||
updated_at: '2026-06-20T22:55:37.790408+08:00',
|
||||
playable: true,
|
||||
version_name: 'v1.0.0',
|
||||
play_url: 'https://square.nianxx.cn/apps/space-cleaner/',
|
||||
runtime_url: null,
|
||||
},
|
||||
],
|
||||
next_cursor: null,
|
||||
limit: 24,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(response.json())).not.toContain('private@example.com');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://square.nianxx.cn/api/projects?q=space&category=game&limit=24',
|
||||
{ method: 'GET' },
|
||||
);
|
||||
});
|
||||
|
||||
it('uses runtime_url only as a trusted fallback and fails closed on unsafe primary data', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
app_id: 'legacy-game',
|
||||
title: 'Legacy Game',
|
||||
summary: 'Compatibility release',
|
||||
playable: true,
|
||||
version_name: 'v1.0.0',
|
||||
play_url: null,
|
||||
runtime_url: '/apps/legacy-game/',
|
||||
},
|
||||
{
|
||||
app_id: 'unsafe-game',
|
||||
title: 'Unsafe Game',
|
||||
summary: 'Must not launch',
|
||||
playable: true,
|
||||
version_name: 'v1.0.0',
|
||||
play_url: 'https://evil.example/apps/unsafe-game/',
|
||||
runtime_url: '/apps/unsafe-game/',
|
||||
},
|
||||
{
|
||||
app_id: 'unversioned-game',
|
||||
title: 'Unversioned Game',
|
||||
summary: 'Missing release version',
|
||||
playable: true,
|
||||
play_url: '/apps/unversioned-game/',
|
||||
},
|
||||
],
|
||||
next_cursor: null,
|
||||
limit: 24,
|
||||
}), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
await handleWorksRoutes(
|
||||
createRequest('GET'),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
success: true,
|
||||
page: {
|
||||
items: [
|
||||
{
|
||||
app_id: 'legacy-game',
|
||||
title: 'Legacy Game',
|
||||
summary: 'Compatibility release',
|
||||
playable: true,
|
||||
version_name: 'v1.0.0',
|
||||
play_url: null,
|
||||
runtime_url: 'https://square.nianxx.cn/apps/legacy-game/',
|
||||
},
|
||||
{
|
||||
app_id: 'unsafe-game',
|
||||
title: 'Unsafe Game',
|
||||
summary: 'Must not launch',
|
||||
playable: false,
|
||||
version_name: 'v1.0.0',
|
||||
play_url: null,
|
||||
runtime_url: null,
|
||||
},
|
||||
{
|
||||
app_id: 'unversioned-game',
|
||||
title: 'Unversioned Game',
|
||||
summary: 'Missing release version',
|
||||
playable: false,
|
||||
play_url: null,
|
||||
runtime_url: null,
|
||||
},
|
||||
],
|
||||
next_cursor: null,
|
||||
limit: 24,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('lists public assets through the Works Square API', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
@@ -363,6 +465,9 @@ describe('works square host api routes', () => {
|
||||
age_band: '8-12',
|
||||
difficulty: 'beginner',
|
||||
updated_at: '2026-06-20T22:55:37.790408+08:00',
|
||||
playable: false,
|
||||
play_url: null,
|
||||
runtime_url: null,
|
||||
},
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
@@ -401,6 +506,7 @@ describe('works square host api routes', () => {
|
||||
status: 'draft',
|
||||
updated_at: '2026-06-20T22:55:37.790408+08:00',
|
||||
playable: false,
|
||||
play_url: null,
|
||||
runtime_url: null,
|
||||
},
|
||||
],
|
||||
@@ -435,6 +541,7 @@ describe('works square host api routes', () => {
|
||||
status: 'draft',
|
||||
updated_at: '2026-06-20T22:55:37.790408+08:00',
|
||||
playable: false,
|
||||
play_url: null,
|
||||
runtime_url: null,
|
||||
},
|
||||
],
|
||||
@@ -743,7 +850,7 @@ describe('works square host api routes', () => {
|
||||
status: 'draft',
|
||||
updated_at: '2026-06-20T22:55:37.790408+08:00',
|
||||
playable: false,
|
||||
play_url: '/apps/space-cleaner/',
|
||||
play_url: null,
|
||||
runtime_url: null,
|
||||
},
|
||||
latest_version: {
|
||||
@@ -832,7 +939,12 @@ describe('works square host api routes', () => {
|
||||
expect(response.json()).toEqual({
|
||||
success: true,
|
||||
status: {
|
||||
project: status.project,
|
||||
project: {
|
||||
...status.project,
|
||||
playable: false,
|
||||
play_url: null,
|
||||
runtime_url: null,
|
||||
},
|
||||
latest_version: {
|
||||
id: 'version-new',
|
||||
version_name: 'v1.1.0',
|
||||
@@ -911,6 +1023,42 @@ describe('works square host api routes', () => {
|
||||
expect(JSON.stringify(response.json())).not.toContain('token=secret');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing', {}],
|
||||
['incorrect', { [RENDERER_CAPABILITY_HEADER]: 'incorrect-capability' }],
|
||||
])('rejects a %s Renderer capability before credentials or project files are read', async (_case, headers) => {
|
||||
const fetchMock = vi.fn();
|
||||
const listProjects = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
const handled = await handleWorksRoutes(
|
||||
createRequest('POST', {
|
||||
projectId: 'project-1',
|
||||
project: {
|
||||
app_id: 'space-cleaner',
|
||||
title: 'Space Cleaner',
|
||||
summary: 'Catch space trash',
|
||||
},
|
||||
}, headers),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
{ opencodeProjectStore: { listProjects } } as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json()).toEqual({
|
||||
success: false,
|
||||
status: 403,
|
||||
code: 'RENDERER_CAPABILITY_REQUIRED',
|
||||
error: 'Renderer capability required',
|
||||
});
|
||||
expect(getValidWorksSquareAccessTokenMock).not.toHaveBeenCalled();
|
||||
expect(listProjects).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('packages and submits source with Main-owned credentials and automatic release metadata', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-publish-'));
|
||||
await writePublishableProject(tempDir);
|
||||
@@ -936,7 +1084,7 @@ describe('works square host api routes', () => {
|
||||
const recordSubmitted = vi.fn(async () => undefined);
|
||||
|
||||
const handled = await handleWorksRoutes(
|
||||
createRequest('POST', { projectId: project.id, project: projectMetadata }),
|
||||
createRendererRequest('POST', { projectId: project.id, project: projectMetadata }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
{
|
||||
@@ -1021,7 +1169,7 @@ describe('works square host api routes', () => {
|
||||
const response = createResponse();
|
||||
|
||||
await handleWorksRoutes(
|
||||
createRequest('POST', {
|
||||
createRendererRequest('POST', {
|
||||
projectId: project.id,
|
||||
project: {
|
||||
app_id: 'space-cleaner',
|
||||
@@ -1043,7 +1191,12 @@ describe('works square host api routes', () => {
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
upload: { version_id: 'version-1', review_status: 'building' },
|
||||
binding_warning: {
|
||||
code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED',
|
||||
message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。',
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(response.json())).not.toContain('disk unavailable');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -1062,7 +1215,7 @@ describe('works square host api routes', () => {
|
||||
const response = createResponse();
|
||||
|
||||
await handleWorksRoutes(
|
||||
createRequest('POST', {
|
||||
createRendererRequest('POST', {
|
||||
projectId: project.id,
|
||||
project: {
|
||||
app_id: 'space-cleaner',
|
||||
@@ -1105,7 +1258,7 @@ describe('works square host api routes', () => {
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const response = createResponse();
|
||||
await handleWorksRoutes(
|
||||
createRequest('POST', { projectId: project.id, project: projectMetadata }),
|
||||
createRendererRequest('POST', { projectId: project.id, project: projectMetadata }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
ctx,
|
||||
@@ -1144,7 +1297,7 @@ describe('works square host api routes', () => {
|
||||
const response = createResponse();
|
||||
|
||||
await handleWorksRoutes(
|
||||
createRequest('POST', {
|
||||
createRendererRequest('POST', {
|
||||
projectId: project.id,
|
||||
project: {
|
||||
app_id: 'space-cleaner',
|
||||
@@ -1180,7 +1333,7 @@ describe('works square host api routes', () => {
|
||||
const response = createResponse();
|
||||
|
||||
await handleWorksRoutes(
|
||||
createRequest('POST', {
|
||||
createRendererRequest('POST', {
|
||||
projectId: project.id,
|
||||
project: {
|
||||
app_id: 'space-cleaner',
|
||||
@@ -1212,7 +1365,7 @@ describe('works square host api routes', () => {
|
||||
const response = createResponse();
|
||||
|
||||
await handleWorksRoutes(
|
||||
createRequest('POST', {
|
||||
createRendererRequest('POST', {
|
||||
projectId: 'project-1',
|
||||
project: { app_id: 'space-cleaner', title: 'Space Cleaner', summary: 'Catch trash' },
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user