补强静态发布安全边界

This commit is contained in:
2026-08-10 18:27:53 +08:00
parent 4df047708b
commit 8dd99c1855
15 changed files with 560 additions and 85 deletions

View File

@@ -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' },
}),