补强静态发布安全边界

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

@@ -4,7 +4,7 @@ import path from 'node:path';
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
test.describe('Project-level Superpowers setting', () => {
test('starts new projects with Superpowers disabled', async ({ launchElectronApp }) => {
test('starts publishable projects with Superpowers disabled and only the one-click release entry', async ({ launchElectronApp }) => {
const parentPath = await mkdtemp(path.join(tmpdir(), 'niancode-superpowers-e2e-'));
const app = await launchElectronApp({ skipSetup: true });
@@ -36,7 +36,9 @@ test.describe('Project-level Superpowers setting', () => {
await expect(page.getByText('已关闭')).toBeVisible();
await page.getByRole('button', { name: '关闭' }).click();
await expect(page.getByTestId('resource-card-publish')).toHaveCount(0);
await expect(page.getByRole('button', { name: '一键提交审核' })).toBeVisible();
await expect(page.getByRole('button', { name: '一键提交审核' })).toHaveCount(1);
await expect(page.getByText('自动部署', { exact: false })).toHaveCount(0);
await expect(page.getByText('部署发布检查', { exact: false })).toHaveCount(0);
} finally {
await closeElectronApp(app);
await rm(parentPath, { recursive: true, force: true });

View File

@@ -221,7 +221,7 @@ describe('device preview Host API route', () => {
it('keeps the legacy runtime alias as a one-release fallback', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(
JSON.stringify(matchingRemotePayload({
project: { play_url: null, runtime_url: '/apps/runtime-fallback/' },
project: { play_url: null, runtime_url: '/apps/planet-game/' },
})),
{ status: 200 },
)));
@@ -237,7 +237,7 @@ describe('device preview Host API route', () => {
expect(response.json()).toMatchObject({
preview: {
state: 'ready',
launchUrl: 'https://square.nianxx.cn/apps/runtime-fallback/',
launchUrl: 'https://square.nianxx.cn/apps/planet-game/',
},
});
});

View File

@@ -141,6 +141,33 @@ describe('ProjectPublishAction', () => {
expect(screen.getByRole('button', { name: '已提交,请稍后查看' })).toBeDisabled();
});
it('shows a safe local binding warning while continuing cloud build polling', async () => {
publishWorksProjectSourceMock.mockResolvedValueOnce({
package: {},
upload,
bindingWarning: {
code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED',
message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。',
},
});
fetchCurrentWorksProjectStatusMock.mockResolvedValue(projectStatus('succeeded'));
render(<ProjectPublishAction project={project} projectType="mini_game" />);
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
await flushSubmission();
expect(screen.getByTestId('project-publish-binding-warning')).toHaveTextContent(
'已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。',
);
expect(screen.getByRole('button', { name: '正在等待云端检查…' })).toBeDisabled();
await advancePoll();
expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledOnce();
expect(screen.getByRole('button', { name: '已提交,等待运营审核' })).toBeDisabled();
expect(screen.getByTestId('project-publish-binding-warning')).toBeVisible();
});
it('shows a safe timeout instead of inviting a duplicate submission', async () => {
const status = projectStatus('queued');
status.latest_version.id = 'another-version';

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
trustedWorksProjectPlayUrl,
trustedWorksReleasePreviewUrl,
} from '@electron/api/works-play-url';
const worksBase = new URL('https://square.nianxx.cn/');
describe('Works Square playable URL security', () => {
it('normalizes only the exact same-origin HTTPS app path', () => {
expect(trustedWorksProjectPlayUrl('/apps/space-cleaner/', worksBase, 'space-cleaner'))
.toBe('https://square.nianxx.cn/apps/space-cleaner/');
expect(trustedWorksProjectPlayUrl(
'/apps/space%20cleaner/',
worksBase,
'space cleaner',
)).toBe('https://square.nianxx.cn/apps/space%20cleaner/');
});
it.each([
['cross origin', 'https://evil.example/apps/space-cleaner/', worksBase],
['HTTP', 'http://square.nianxx.cn/apps/space-cleaner/', worksBase],
['userinfo', 'https://user:secret@square.nianxx.cn/apps/space-cleaner/', worksBase],
['loopback base', '/apps/space-cleaner/', new URL('https://127.0.0.1:8443/')],
['wrong app path', '/apps/another-app/', worksBase],
['nested path', '/apps/space-cleaner/index.html', worksBase],
['query string', '/apps/space-cleaner/?token=secret', worksBase],
['fragment', '/apps/space-cleaner/#start', worksBase],
['overlong URL', `/apps/space-cleaner/${'x'.repeat(1_100)}`, worksBase],
])('rejects %s', (_case, value, base) => {
expect(trustedWorksProjectPlayUrl(value, base, 'space-cleaner')).toBeNull();
});
it('keeps signed release previews on their exact release prefix', () => {
expect(trustedWorksReleasePreviewUrl(
'/previews/release-7/signed-ticket/?signature=ok',
worksBase,
'release-7',
)).toBe('https://square.nianxx.cn/previews/release-7/signed-ticket/?signature=ok');
expect(trustedWorksReleasePreviewUrl(
'/previews/release-8/signed-ticket/',
worksBase,
'release-7',
)).toBeNull();
});
});

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

View File

@@ -339,6 +339,10 @@ describe('works square client', () => {
build_job_id: 'job-1',
build_status: 'queued',
},
binding_warning: {
code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED',
message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。',
},
};
hostApiFetchMock.mockResolvedValueOnce({ success: true, ...publishResult });
@@ -352,7 +356,11 @@ describe('works square client', () => {
},
});
expect(result).toEqual(publishResult);
expect(result).toEqual({
package: publishResult.package,
upload: publishResult.upload,
bindingWarning: publishResult.binding_warning,
});
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/projects/publish-source', {
method: 'POST',
body: JSON.stringify({

View File

@@ -127,4 +127,52 @@ describe('Works Square submission binding store', () => {
review_status: 'approved',
});
});
it.each([
['submitted without a version id', {
status: 'submitted',
app_id: 'planet-game',
version_name: 'v0.7.0',
}],
['retired without its fixed error code', {
status: 'legacy_retired',
message: '请重新提交',
}],
])('rejects a malformed schema 2 record: %s', async (_case, fields) => {
const project = await createProject();
await writeFile(
join(project.path, WORKS_SUBMISSION_BINDING_FILE_NAME),
`${JSON.stringify({
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
project_id: project.id,
requested_at: '2026-08-10T00:00:00.000Z',
updated_at: '2026-08-10T00:05:00.000Z',
...fields,
})}\n`,
'utf8',
);
await expect(readWorksSubmissionBinding(project.path, project.id)).resolves.toBeNull();
});
it('rejects a copied binding whose project id does not match the current project', async () => {
const project = await createProject();
await writeFile(
join(project.path, WORKS_SUBMISSION_BINDING_FILE_NAME),
`${JSON.stringify({
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
project_id: 'another-project',
status: 'submitted',
requested_at: '2026-08-10T00:00:00.000Z',
updated_at: '2026-08-10T00:05:00.000Z',
app_id: 'planet-game',
version_id: 'version-7',
version_name: 'v0.7.0',
})}\n`,
'utf8',
);
const store = createWorksSubmissionBindingStore(createStore(project));
await expect(store.get(project.id)).resolves.toBeNull();
});
});