补强静态发布安全边界
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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/',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
46
tests/unit/works-play-url.test.ts
Normal file
46
tests/unit/works-play-url.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
}),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user